fix(import): consistent add-source forms with masked passwords and URL errors (#1796)

* fix(import): consistent add-source forms with masked passwords and URL errors

- Mask the Xtream password in add and edit (and the Stalker one in edit)
  behind a shared PasswordVisibilityToggleDirective: one translated
  "Show password" label, state in aria-pressed, type="button".
- Give the Xtream server URL its own mat-error and a neutral hint instead
  of the EPG file error; give the M3U URL a mat-error.
- Use "Playlist title" in every add form, "MAC address" casing, a single
  ellipsis in "Validating portal…" and one "Add playlist" submit label;
  translate the method radiogroup's aria-label.
- Show Stalker refusals inline under the portal URL (role="status", like
  the Xtream connection test), translated in the template and cleared by
  edits; translate the snackbars for outcomes that close the dialog.
- Translate new strings into all locales; reuse the identical Stalker URL
  error translations; fix MAC casing and ellipses; drop unused keys.
- Unit specs per form, edit-dialog spec, new add-source-forms web E2E;
  update E2E locators; UI guidelines Forms section; Stalker contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): mask the password again when an add form is cleared

Clear erased the password but left the visibility toggle on, so the next
password typed in the Xtream or Stalker form showed in plain text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-03 13:13:54 +02:00
1 parent 8e1cb05913
commit a8dd1eaa97
56 files changed
+1278 -252

No files matched your search

@@ -0,0 +1,6 @@
---
type: fix
area: import
---
The add-source forms now look and behave alike. Xtream and Stalker passwords are hidden, with an eye button to show them, also when you edit a source. Server URL fields explain what is wrong with an address. Every form ends with "Add playlist". When a Stalker portal refuses a source, the reason appears in the form, in your language.
@@ -193,7 +193,9 @@ async function importDashPlaylistFromText(
const dialog = app.mainWindow.locator('mat-dialog-container').last();
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
await dialog.locator('textarea').fill(playlist);
await dialog.getByRole('button', { name: 'Import', exact: true }).click();
await dialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click();
await dialog.waitFor({ state: 'detached' });
await waitForM3uCatalog(app.mainWindow);
}
+1 -1
View File
@@ -91,7 +91,7 @@ test.describe('Electron Sources View', () => {
path: test.info().outputPath('http-connection-add.png'),
});
await dialog
.getByRole('button', { name: 'Add', exact: true })
.getByRole('button', { name: 'Add playlist', exact: true })
.click();
await waitForXtreamCatalog(page);
await openSources(page);
@@ -40,7 +40,9 @@ export async function addLiveFormatPortal(page: Page) {
await dialog.locator('#serverUrl').fill(liveFormatMock);
await dialog.locator('#username').fill('live-fallback');
await dialog.locator('#password').fill('live-fallback');
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await dialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click();
await page.waitForURL(/xtreams.*vod/);
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
await page.locator('.context-panel .category-item').first().click();
+117
View File
@@ -0,0 +1,117 @@
import type { Locator, Page } from '@playwright/test';
import { setInputValue } from './e2e-helpers';
import { expect, test } from './fixtures';
import {
XTREAM_MOCK_SERVER,
interceptPwaProviderRequests,
resetPwaMockServers,
} from './sources-pwa.helpers';
/**
* The three add-source forms share one vocabulary: the same name label, a
* masked password with a show/hide toggle, a URL error of their own and the
* single "Add playlist" submit. Credentials are the Xtream mock's fixtures.
*/
test.beforeEach(async ({ page, request }) => {
await resetPwaMockServers(request);
await interceptPwaProviderRequests(page);
await page.goto('/');
});
async function openMethod(page: Page, method: RegExp): Promise<Locator> {
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: method }).click();
return dialog;
}
async function expectPasswordToggle(dialog: Locator): Promise<void> {
const password = dialog.locator('#password');
const toggle = dialog.getByRole('button', { name: 'Show password' });
await expect(password).toHaveAttribute('type', 'password');
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
await toggle.click();
await expect(password).toHaveAttribute('type', 'text');
await expect(toggle).toHaveAttribute('aria-pressed', 'true');
await toggle.click();
await expect(password).toHaveAttribute('type', 'password');
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
}
test('@sources @pwa adds an Xtream source through the dialog', async ({
page,
}) => {
const dialog = await openMethod(page, /Xtream credentials/i);
const serverUrl = dialog.locator('#serverUrl');
await expect(
dialog.getByText('Server address, e.g. http://host:port')
).toBeVisible();
await setInputValue(serverUrl, 'panel.example');
await serverUrl.blur();
await expect(dialog.locator('mat-error')).toHaveText(
'Enter a valid http:// or https:// URL.'
);
// The field used to borrow the EPG source error.
await expect(dialog.getByText(/absolute file path/i)).toHaveCount(0);
await setInputValue(
dialog.getByRole('textbox', { name: 'Playlist title' }),
'Dialog Xtream Source'
);
await setInputValue(serverUrl, XTREAM_MOCK_SERVER);
await expect(dialog.locator('mat-error')).toHaveCount(0);
await setInputValue(dialog.locator('#username'), 'user1');
await setInputValue(dialog.locator('#password'), 'pass1');
await expectPasswordToggle(dialog);
await expect(dialog.locator('#password')).toHaveValue('pass1');
const add = dialog.getByRole('button', {
name: 'Add playlist',
exact: true,
});
await expect(add).toBeEnabled({ timeout: 10_000 });
await add.click();
await expect(dialog).toBeHidden();
await page.waitForURL(/xtreams.*vod/);
});
test('@sources @pwa explains an invalid M3U playlist URL', async ({ page }) => {
const dialog = await openMethod(page, /M3U URL/i);
const url = dialog.getByRole('textbox', {
name: 'Playlist URL (m3u, m3u8)',
});
await expect(
dialog.getByRole('textbox', { name: 'Playlist title' })
).toBeVisible();
await setInputValue(url, 'playlist.m3u');
await url.blur();
await expect(dialog.locator('mat-error')).toHaveText(
'Enter a valid http:// or https:// URL.'
);
await expect(
dialog.getByRole('button', { name: 'Add playlist', exact: true })
).toBeDisabled();
});
test('@sources @pwa gives the Stalker form the shared labels and a masked password', async ({
page,
}) => {
const dialog = await openMethod(page, /Stalker portal/i);
await expect(
dialog.getByRole('textbox', { name: 'Playlist title' })
).toBeVisible();
await expect(
dialog.getByRole('textbox', { name: 'MAC address', exact: true })
).toBeVisible();
await setInputValue(dialog.locator('#password'), 'secret');
await expectPasswordToggle(dialog);
await expect(
dialog.getByRole('button', { name: 'Add playlist', exact: true })
).toBeVisible();
});
+8 -5
View File
@@ -82,14 +82,17 @@ test('@web @auto-detect pasted provider message prefills the Xtream form', async
'http://tv.example.com:8080'
);
await expect(dialog.getByLabel('Username')).toHaveValue('e2euser');
await expect(dialog.getByLabel('Password')).toHaveValue('e2epass');
// Exact: the show/hide toggle beside it is labelled "Show password".
await expect(dialog.getByLabel('Password', { exact: true })).toHaveValue(
'e2epass'
);
await expect(dialog.getByLabel('Playlist title')).toHaveValue(
'tv.example.com'
);
// Detection only proposes: the regular Add action stays in charge.
await expect(
dialog.getByRole('button', { name: 'Add', exact: true })
dialog.getByRole('button', { name: 'Add playlist', exact: true })
).toBeEnabled();
});
@@ -114,9 +117,9 @@ test('@web @auto-detect keeps the pasted message when switching methods', async
).toBeChecked();
// Exact: the derive-device-IDs checkbox is labelled "Generate device IDs
// from the MAC address", which a substring match would also resolve to.
await expect(
dialog.getByLabel('Mac Address', { exact: true })
).toHaveValue('00:1A:79:12:34:56');
await expect(dialog.getByLabel('MAC address', { exact: true })).toHaveValue(
'00:1A:79:12:34:56'
);
// Returning to auto-detect must not cost the user their paste.
await dialog.getByRole('radio', { name: /Auto-detect/i }).click();
+3 -1
View File
@@ -131,7 +131,9 @@ async function importDashPlaylist(page: Page): Promise<void> {
await dialog.locator('textarea').fill(DASH_PLAYLIST);
await Promise.all([
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
dialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click(),
]);
await expect(page.getByText('3 channels')).toBeVisible();
}
+3 -1
View File
@@ -123,7 +123,9 @@ async function importPlaylist(
await dialog.getByLabel('Insert m3u(8) playlist as text').fill(content);
await Promise.all([
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
dialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click(),
]);
await expect(page.getByText(`${count} channels`)).toBeVisible();
}
@@ -99,7 +99,9 @@ async function importFatalHlsPlaylist(page: Page): Promise<void> {
.fill(FATAL_HLS_PLAYLIST);
await Promise.all([
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
dialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click(),
]);
await expect(page.getByText('1 channel')).toBeVisible();
}
@@ -76,7 +76,9 @@ export async function importPlaylist(page: Page): Promise<string> {
await dialog.getByLabel('Insert m3u(8) playlist as text').fill(PLAYLIST);
await Promise.all([
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
dialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click(),
]);
await expect(page.getByText('1 channels')).toBeVisible();
return page.url();
+7 -2
View File
@@ -38,7 +38,9 @@ async function addXtreamPortal(page: Page): Promise<void> {
await dialog.locator('#username').fill('user1');
await dialog.locator('#password').fill('pass1');
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await dialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click();
await expect(dialog).toBeHidden();
await page.waitForURL(/xtreams.*vod/);
}
@@ -75,7 +77,10 @@ async function addStalkerPortal(page: Page): Promise<void> {
await setInputValue(dialog.locator('input#portalUrl'), STALKER_PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), DEFAULT_MAC);
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
const addButton = dialog.getByRole('button', {
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden();
+2 -2
View File
@@ -126,7 +126,7 @@ export async function addXtreamPortal(
await setInputValue(dialog.locator('#password'), 'pass1');
const addButton = dialog.getByRole('button', {
name: 'Add',
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
@@ -172,7 +172,7 @@ export async function addStalkerPortal(
await setInputValue(dialog.locator('input#macAddress'), DEFAULT_MAC);
const addButton = dialog.getByRole('button', {
name: 'Add',
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
+25 -15
View File
@@ -240,7 +240,10 @@ async function addStalkerPortal(
await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), mac);
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
const addButton = dialog.getByRole('button', {
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden();
@@ -284,7 +287,10 @@ async function addFullStalkerPortal(
await setInputValue(dialog.locator('input#password'), password);
}
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
const addButton = dialog.getByRole('button', {
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden();
@@ -1387,15 +1393,17 @@ test.describe('@stalker full portal authentication', () => {
await setInputValue(dialog.locator('input#macAddress'), mac);
const addButton = dialog.getByRole('button', {
name: 'Add',
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(
page.getByText(/requires a login and password/i)
).toBeVisible({ timeout: 15_000 });
// Inline under the portal URL, like the Xtream connection test.
await expect(dialog.getByRole('status')).toContainText(
/requires a login and password/i,
{ timeout: 15_000 }
);
// The dialog stays open so the user can add the credentials.
await expect(dialog).toBeVisible();
@@ -1508,7 +1516,7 @@ test.describe('@stalker full portal authentication', () => {
);
const addButton = dialog.getByRole('button', {
name: 'Add',
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
@@ -1518,9 +1526,10 @@ test.describe('@stalker full portal authentication', () => {
// stop there instead of persisting a portal that can never load. (It
// used to be treated as success whenever the portal sent no msg text,
// which imported a dead source.)
await expect(page.getByText(/refused access/i)).toBeVisible({
timeout: 15_000,
});
await expect(dialog.getByRole('status')).toContainText(
/refused access/i,
{ timeout: 15_000 }
);
await expect(dialog).toBeVisible();
await expect(page).not.toHaveURL(/stalker/);
@@ -1570,7 +1579,7 @@ test.describe('@stalker full portal authentication', () => {
);
const addButton = dialog.getByRole('button', {
name: 'Add',
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
@@ -1579,12 +1588,13 @@ test.describe('@stalker full portal authentication', () => {
// The conflict gets its own headline. Asserting the generic one is
// ABSENT is what makes this test fail if the classification is
// removed — `blocked` would still surface the portal's text.
await expect(
page.getByText(/different device ID registered/i)
).toBeVisible({ timeout: 15_000 });
const status = dialog.getByRole('status');
await expect(status).toContainText(/different device ID registered/i, {
timeout: 15_000,
});
await expect(page.getByText(/refused access/i)).toHaveCount(0);
// The portal's own words still travel with it, markup stripped.
await expect(page.getByText(/device_id mismatch/i)).toBeVisible();
await expect(status).toContainText(/device_id mismatch/i);
await expect(page.locator('body')).not.toContainText('<br/>');
await expect(dialog).toBeVisible();
+1 -1
View File
@@ -75,7 +75,7 @@ for (const canTryHttp of [true, false]) {
: ['https://panel.example/base']
);
await expect(
dialog.getByRole('button', { name: 'Add', exact: true })
dialog.getByRole('button', { name: 'Add playlist', exact: true })
).toBeEnabled();
});
}
@@ -40,7 +40,9 @@ export async function addLiveFormatPortal(page: Page) {
await dialog.locator('#serverUrl').fill(liveFormatMock);
await dialog.locator('#username').fill('live-fallback');
await dialog.locator('#password').fill('live-fallback');
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await dialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click();
await page.waitForURL(/xtreams.*vod/);
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
await page.locator('.context-panel .category-item').first().click();
@@ -75,7 +75,9 @@ export async function addXtreamPortal(
await setInputValue(dialog.locator('#username'), username);
await setInputValue(dialog.locator('#password'), password);
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await dialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
await page.waitForURL(/xtreams.*vod/);
}
+9 -7
View File
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "اختر طريقة الاتصال، ويمكنك إعادة تسميتها لاحقًا.",
"METHOD_GROUP_LABEL": "طريقة الاتصال",
"METHOD_URL_LABEL": "رابط M3U",
"METHOD_URL_SUB": "ألصق رابطًا لملف .m3u أو .m3u8 بعيد",
"METHOD_FILE_LABEL": "ملف M3U",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "رابط قائمة التشغيل (m3u، m3u8)",
"URL_VALIDATION_ERROR": "أدخل رابطًا صالحًا يبدأ بـ http:// أو https://.",
"ADD_PLAYLIST": "إضافة القائمة",
"CORS_NOTE": "ملاحظة: لتجنب مشاكل CORS، يستخدم التطبيق خدمة بروكسي عامة. يُنصح باستيراد القوائم الحساسة عبر الملفات.",
"ERROR_FETCH_FAILED": "فشل جلب قائمة التشغيل. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "تابع فقط إذا كنت تثق بمضيف قائمة التشغيل هذا. سيسمح IPTVnator بالشهادات غير الصالحة لهذا المضيف، بينما ستظل المضيفات الأخرى تتطلب شهادات صالحة."
},
"TEXT_IMPORT": {
"LABEL": "إدراج قائمة تشغيل M3U(8) كنص",
"BUTTON_LABEL": "استيراد"
"LABEL": "إدراج قائمة تشغيل M3U(8) كنص"
},
"XTREAM_PLAYLIST": {
"TITLE": "عنوان القائمة",
"SERVER_URL": "رابط الخادم",
"USERNAME": "اسم المستخدم",
"PASSWORD": "كلمة المرور",
"ADD": "إضافة",
"TEST_CREDENTIALS": "اختبار بيانات الاعتماد",
"URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com)",
"URL_VALIDATION_ERROR": "أدخل رابطًا صالحًا يبدأ بـ http:// أو https://.",
"URL_HINT": "عنوان الخادم، مثال: http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "أدخل اسم المستخدم وكلمة المرور لاختبار الاتصال.",
"TESTING": "جارٍ اختبار الاتصال…",
@@ -199,7 +200,6 @@
}
},
"STALKER_PORTAL": {
"TITLE": "العنوان",
"MAC_ADDRESS": "عنوان MAC",
"MAC_ADDRESS_HINT": "التنسيق: 00:1A:79:XX:XX:XX — تُصحَّح الشرطات والنقاط والأحرف الصغيرة تلقائيًا.",
"MAC_ADDRESS_OUI_HINT": "هذا العنوان خارج النطاق 00:1A:79 الخاص بشركة Infomir. ومعظم البوابات لا تقبل سوى هذا النطاق وترفض ما عداه دون بيان السبب.",
@@ -218,8 +218,9 @@
"SERVER_URL": "رابط الخادم",
"URL_HINT": "أدخل اسم مضيف أو عنوانًا ينتهي بـ /c أو portal.php أو server/load.php.",
"URL_VALIDATION_ERROR": "أدخل رابطًا صالحًا يبدأ بـ http:// أو https://.",
"ADD": "إضافة",
"VALIDATING": "جارٍ التحقق من البوابة...",
"VALIDATING": "جارٍ التحقق من البوابة…",
"VALIDATED_EXPIRES": "تم التحقق من البوابة. ينتهي الاشتراك في {{date}}.",
"ADDED_WITHOUT_VALIDATION": "لم تستجب البوابة. تمت إضافتها دون التحقق منها.",
"CREDENTIALS_HINT": "مطلوب فقط عندما تطلب البوابة اسم مستخدم وكلمة مرور",
"AUTH_FAILED": "فشلت المصادقة مع البوابة. تحقق من الرابط وعنوان MAC.",
"EDIT_UNREACHABLE": "لم تستجب البوابة. لم يتم حفظ أي تغييرات. تحقق من الرابط وحاول مرة أخرى.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "لدى البوابة معرّف جهاز مختلف مسجَّل لعنوان MAC هذا.",
"PORTAL_MESSAGE": "أفادت البوابة: {{message}}"
},
"SHOW_PASSWORD": "إظهار كلمة المرور",
"FILTER_BY_NAME": "التصفية حسب الاسم",
"FILTER_AND_SORT": "تصفية وفرز",
"SORT_PLAYLISTS": "فرز القوائم",
+9 -7
View File
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "اختار كيفاش بغيتي تربط. تقدر تبدل الاسم من بعد.",
"METHOD_GROUP_LABEL": "طريقة الربط",
"METHOD_URL_LABEL": "رابط M3U",
"METHOD_URL_SUB": "لصق رابط ل ملف .m3u ولا .m3u8 بعيد",
"METHOD_FILE_LABEL": "ملف M3U",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "رابط قائمة التشغيل (m3u, m3u8)",
"URL_VALIDATION_ERROR": "دخل رابط صحيح كيبدا بـ http:// ولا https://.",
"ADD_PLAYLIST": "زيد قائمة التشغيل",
"CORS_NOTE": "ملاحظة: باش نتفاداو مشاكل CORS فهاد المرحلة، التطبيق كيستعمل خدمة وكيل CORS عامة. إلى بغيتي ترفع قائمة تشغيل فيها بيانات حساسة، من الأحسن تستوردها من ملف.",
"ERROR_FETCH_FAILED": "فشل جلب قائمة التشغيل. عفاك تحقق من الرابط وحاول مرة أخرى.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "كمل غير إلا كنتي كتوثق فهاد مضيف قائمة التشغيل. IPTVnator غادي يسمح بشهادات TLS ما صالحةش لهد المضيف، ولكن باقي المضيفين خاصهم شهادات صالحة."
},
"TEXT_IMPORT": {
"LABEL": "دخل قائمة تشغيل m3u(8) على شكل نص",
"BUTTON_LABEL": "استورد"
"LABEL": "دخل قائمة تشغيل m3u(8) على شكل نص"
},
"XTREAM_PLAYLIST": {
"TITLE": "عنوان قائمة التشغيل",
"SERVER_URL": "رابط الخادم",
"USERNAME": "اسم المستخدم",
"PASSWORD": "كلمة المرور",
"ADD": "زيد",
"TEST_CREDENTIALS": "جرب بيانات الاعتماد",
"URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com ولا https://example.com:4324)",
"URL_VALIDATION_ERROR": "دخل رابط صحيح كيبدا بـ http:// ولا https://.",
"URL_HINT": "عنوان الخادم، مثلا http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "دخل اسم المستخدم وكلمة السر باش تجرّب الاتصال.",
"TESTING": "كنجرّبو الاتصال…",
@@ -199,7 +200,6 @@
}
},
"STALKER_PORTAL": {
"TITLE": "العنوان",
"MAC_ADDRESS": "عنوان MAC",
"MAC_ADDRESS_HINT": "الصيغة 00:1A:79:XX:XX:XX. الشرطات والنقط والحروف الصغيرة كيتصلحو ليك أوتوماتيكياً.",
"MAC_ADDRESS_OUI_HINT": "هاد العنوان خارج المجال 00:1A:79 ديال Infomir. أغلب البوابات كتقبل غير هاد المجال وكترفض أي شي آخر بلا ما تقول علاش.",
@@ -218,8 +218,9 @@
"SERVER_URL": "رابط الخادم",
"URL_HINT": "دخل الهوست ولا عنوان كيسالي بـ /c ولا portal.php ولا server/load.php.",
"URL_VALIDATION_ERROR": "دخل رابط صحيح كيبدا بـ http:// ولا https://.",
"ADD": "زيد",
"VALIDATING": "جاري التحقق من البوابة...",
"VALIDATING": "جاري التحقق من البوابة…",
"VALIDATED_EXPIRES": "البوابة تحققات. كتسالي ف {{date}}.",
"ADDED_WITHOUT_VALIDATION": "البوابة ما جاوباتش. تزادت بلا تحقق.",
"CREDENTIALS_HINT": "خاصين غير إذا البوابة كتطلب اسم المستخدم وكلمة المرور",
"AUTH_FAILED": "فشل تسجيل الدخول للبوابة. تحقق من الرابط وعنوان MAC.",
"EDIT_UNREACHABLE": "البوابة ما جاوباتش. ما تحفظ حتى تغيير. تأكد من الرابط وعاود المحاولة.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "البوابة عندها معرف جهاز آخر مسجل لهاد عنوان MAC.",
"PORTAL_MESSAGE": "البوابة قالت: {{message}}"
},
"SHOW_PASSWORD": "وري كلمة المرور",
"FILTER_BY_NAME": "فلتر بالاسم",
"FILTER_AND_SORT": "فلتر وترتيب",
"SORT_PLAYLISTS": "رتب قوائم التشغيل",
+9 -7
View File
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Выберыце, як вы хочаце падключыцца. Перайменаваць можна пазней.",
"METHOD_GROUP_LABEL": "Спосаб падключэння",
"METHOD_URL_LABEL": "M3U URL",
"METHOD_URL_SUB": "Устаўце спасылку на выдалены .m3u або .m3u8",
"METHOD_FILE_LABEL": "M3U-файл",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL плэйліста (m3u, m3u8)",
"URL_VALIDATION_ERROR": "Увядзіце правільны URL, які пачынаецца з http:// або https://.",
"ADD_PLAYLIST": "Дадаць плэйліст",
"CORS_NOTE": "Заўвага: каб пазбегнуць праблем з CORS, праграма выкарыстоўвае публічны CORS-проксі. Калі вы хочаце загрузіць плэйліст з канфідэнцыяльнымі дадзенымі, бяспечней імпартаваць яго з файла.",
"ERROR_FETCH_FAILED": "Не ўдалося атрымаць плэйліст. Калі ласка, праверце URL і паспрабуйце яшчэ раз.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "Працягвайце толькі калі давяраеце гэтаму хосту плэйліста. IPTVnator дазволіць несапраўдныя TLS-сертыфікаты для гэтага хоста, але іншыя хосты ўсё яшчэ патрабуюць сапраўдныя сертыфікаты."
},
"TEXT_IMPORT": {
"LABEL": "Устаўце плэйліст фармату m3u(8) у гэта поле",
"BUTTON_LABEL": "Імпартаваць"
"LABEL": "Устаўце плэйліст фармату m3u(8) у гэта поле"
},
"XTREAM_PLAYLIST": {
"TITLE": "Назва плэйліста",
"SERVER_URL": "Сервер URL",
"USERNAME": "Карыстальнік",
"PASSWORD": "Пароль",
"ADD": "Дадаць",
"TEST_CREDENTIALS": "Праверыць",
"URL_VALIDATION_ERROR": "Павінен быць сапраўдны URL-адрас з пратаколам (напрыклад, http://example.com або https://example.com:4324).",
"URL_VALIDATION_ERROR": "Увядзіце правільны URL, які пачынаецца з http:// або https://.",
"URL_HINT": "Адрас сервера, напрыклад http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Увядзіце імя карыстальніка і пароль для праверкі злучэння.",
"TESTING": "Праверка падключэння…",
@@ -199,7 +200,6 @@
}
},
"STALKER_PORTAL": {
"TITLE": "Назва",
"MAC_ADDRESS": "MAC-адрас",
"MAC_ADDRESS_HINT": "Фармат 00:1A:79:XX:XX:XX. Злучкі, кропкі і малыя літары выпраўляюцца аўтаматычна.",
"MAC_ADDRESS_OUI_HINT": "Гэты адрас не належыць да дыяпазону 00:1A:79 кампаніі Infomir. Большасць парталаў прымае толькі гэты дыяпазон і адхіляе ўсе астатнія адрасы без тлумачэння прычыны.",
@@ -218,8 +218,9 @@
"SERVER_URL": "URL сервера",
"URL_HINT": "Увядзіце хост або адрас, які заканчваецца на /c, portal.php ці server/load.php.",
"URL_VALIDATION_ERROR": "Увядзіце правільны URL, які пачынаецца з http:// або https://.",
"ADD": "Дадаць",
"VALIDATING": "Праверка партала...",
"VALIDATING": "Праверка партала…",
"VALIDATED_EXPIRES": "Партал правераны. Тэрмін падпіскі скончыцца {{date}}.",
"ADDED_WITHOUT_VALIDATION": "Партал не адказаў. Ён дададзены без праверкі.",
"CREDENTIALS_HINT": "Патрэбна толькі тады, калі партал запытвае лагін і пароль",
"AUTH_FAILED": "Не ўдалося аўтэнтыфікавацца на партале. Праверце URL і MAC-адрас.",
"EDIT_UNREACHABLE": "Партал не адказаў. Змены не захаваны. Праверце URL і паўтарыце спробу.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "Для гэтага MAC-адраса на партале зарэгістраваны іншы Device ID.",
"PORTAL_MESSAGE": "Партал паведаміў: {{message}}"
},
"SHOW_PASSWORD": "Паказаць пароль",
"FILTER_BY_NAME": "Фільтраваць па імені",
"FILTER_AND_SORT": "Фільтр і сартаванне",
"SORT_PLAYLISTS": "Сартаваць плэйлісты",
+9 -7
View File
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Wählen Sie aus, wie Sie sich verbinden möchten. Sie können den Namen später ändern.",
"METHOD_GROUP_LABEL": "Verbindungsart",
"METHOD_URL_LABEL": "M3U-URL",
"METHOD_URL_SUB": "Fügen Sie einen Link zu einer .m3u- oder .m3u8-Datei ein",
"METHOD_FILE_LABEL": "M3U-Datei",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Wiedergabelisten-URL (m3u, m3u8)",
"URL_VALIDATION_ERROR": "Geben Sie eine gültige URL mit http:// oder https:// ein.",
"ADD_PLAYLIST": "Playlist hinzufügen",
"CORS_NOTE": "Hinweis: Um CORS-Probleme zu vermeiden, verwendet die Anwendung an dieser Stelle einen öffentlichen CORS-Proxy-Dienst. Wenn Sie eine Playlist mit sensiblen Daten hochladen möchten, ist es sicherer, sie aus einer Datei zu importieren.",
"ERROR_FETCH_FAILED": "Playlist konnte nicht abgerufen werden. Bitte überprüfen Sie die URL und versuchen Sie es erneut.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "Fahre nur fort, wenn du diesem Wiedergabelisten-Host vertraust. IPTVnator erlaubt ungültige TLS-Zertifikate für diesen Host, für andere Hosts sind weiterhin gültige Zertifikate erforderlich."
},
"TEXT_IMPORT": {
"LABEL": "Füge m3u(8)-Wiedergabeliste hier als Text ein",
"BUTTON_LABEL": "Importieren"
"LABEL": "Füge m3u(8)-Wiedergabeliste hier als Text ein"
},
"XTREAM_PLAYLIST": {
"TITLE": "Playlist Titel",
"SERVER_URL": "Server URL",
"USERNAME": "Nutzername",
"PASSWORD": "Passwort",
"ADD": "Hinzufügen",
"TEST_CREDENTIALS": "Verbindung testen",
"URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com oder https://example.com:4324)",
"URL_VALIDATION_ERROR": "Geben Sie eine gültige URL mit http:// oder https:// ein.",
"URL_HINT": "Serveradresse, z. B. http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Gib einen Benutzernamen und ein Passwort ein, um die Verbindung zu testen.",
"TESTING": "Verbindung wird geprüft…",
@@ -199,8 +200,7 @@
}
},
"STALKER_PORTAL": {
"TITLE": "Titel",
"MAC_ADDRESS": "Mac-Adresse",
"MAC_ADDRESS": "MAC-Adresse",
"MAC_ADDRESS_HINT": "Format 00:1A:79:XX:XX:XX. Bindestriche, Punkte und Kleinbuchstaben werden automatisch korrigiert.",
"MAC_ADDRESS_OUI_HINT": "Diese Adresse liegt außerhalb des Infomir-Bereichs 00:1A:79. Die meisten Portale akzeptieren nur diesen Bereich und lehnen alles andere ohne Begründung ab.",
"MAC_ADDRESS_ERROR": "Geben Sie eine 12-stellige MAC-Adresse ein, zum Beispiel 00:1A:79:12:34:56.",
@@ -218,8 +218,9 @@
"SERVER_URL": "Server-URL",
"URL_HINT": "Geben Sie einen Host oder eine Adresse mit /c, portal.php oder server/load.php ein.",
"URL_VALIDATION_ERROR": "Geben Sie eine gültige URL mit http:// oder https:// ein.",
"ADD": "Hinzufügen",
"VALIDATING": "Portal wird überprüft …",
"VALIDATED_EXPIRES": "Portal überprüft. Läuft am {{date}} ab.",
"ADDED_WITHOUT_VALIDATION": "Das Portal hat nicht geantwortet. Es wurde ohne Überprüfung hinzugefügt.",
"CREDENTIALS_HINT": "Nur erforderlich, wenn das Portal Benutzername und Passwort verlangt",
"AUTH_FAILED": "Authentifizierung am Portal fehlgeschlagen. Überprüfen Sie die URL und die MAC-Adresse.",
"EDIT_UNREACHABLE": "Das Portal hat nicht geantwortet. Es wurden keine Änderungen gespeichert. Prüfen Sie die URL und versuchen Sie es erneut.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "Für diese MAC-Adresse ist im Portal eine andere Geräte-ID registriert.",
"PORTAL_MESSAGE": "Das Portal meldet: {{message}}"
},
"SHOW_PASSWORD": "Passwort anzeigen",
"FILTER_BY_NAME": "Nach Namen filtern",
"FILTER_AND_SORT": "Filtern & Sortieren",
"SORT_PLAYLISTS": "Playlisten sortieren",
+12 -10
View File
@@ -86,7 +86,7 @@
"SERVER_URL": "Διεύθηνση URL του σέρβερ",
"USERNAME": "Όνομα χρήστη",
"PASSWORD": "Κωδικός πρόσβασης",
"MAC_ADDRESS": "Mac Address",
"MAC_ADDRESS": "Διεύθυνση MAC",
"PLAYLIST_EXPORT_SUCCESS": "Η εξαγωγή της λίστας αναπαραγωγής ολοκληρώθηκε με επιτυχία.",
"PLAYLIST_EXPORT_ERROR": "Σφάλμα: Η εξαγωγή της λίστας αναπαραγωγής απέτυχε.",
"COPY_URL": "Αντιγραφή διεύθηνσης URL στο πρόχειρο",
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Επιλέξτε τρόπο σύνδεσης. Μπορείτε να τη μετονομάσετε αργότερα.",
"METHOD_GROUP_LABEL": "Τρόπος σύνδεσης",
"METHOD_URL_LABEL": "M3U URL",
"METHOD_URL_SUB": "Επικολλήστε έναν σύνδεσμο σε απομακρυσμένο .m3u ή .m3u8",
"METHOD_FILE_LABEL": "Αρχείο M3U",
@@ -142,7 +143,7 @@
"METHOD_XTREAM_LABEL": "Διαπιστευτήρια Xtream",
"METHOD_XTREAM_SUB": "Σύνδεση με διακομιστή, όνομα χρήστη και κωδικό πρόσβασης",
"METHOD_STALKER_LABEL": "Πύλη Stalker",
"METHOD_STALKER_SUB": "MAC address και URL πύλης",
"METHOD_STALKER_SUB": "Διεύθυνση MAC και URL πύλης",
"METHOD_TEXT_LABEL": "Ακατέργαστο κείμενο m3u",
"METHOD_TEXT_SUB": "Για λίστες μίας χρήσης ή όταν έχετε μόνο κείμενο",
"METHOD_AUTO_LABEL": "Αυτόματη αναγνώριση",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL λίστας αναπαραγωγής (m3u, m3u8)",
"URL_VALIDATION_ERROR": "Εισαγάγετε μια έγκυρη διεύθυνση URL που αρχίζει με http:// ή https://.",
"ADD_PLAYLIST": "Προσθήκη λίστας αναπαραγωγής",
"CORS_NOTE": "Σημείωση: Για την αποφυγή προβλημάτων CORS σε αυτό το σημείο, η εφαρμογή χρησιμοποιεί μια δημόσια υπηρεσία CORS Proxy. Εάν θέλετε να ανεβάσετε μια λίστα αναπαραγωγής με ευαίσθητα δεδομένα, είναι πιο ασφαλές να την εισαγάγετε από ένα αρχείο.",
"ERROR_FETCH_FAILED": "Αποτυχία λήψης της λίστας αναπαραγωγής. Ελέγξτε τη διεύθυνση URL και δοκιμάστε ξανά.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "Συνεχίστε μόνο αν εμπιστεύεστε αυτόν τον host λίστας αναπαραγωγής. Το IPTVnator θα επιτρέψει μη έγκυρα πιστοποιητικά TLS για αυτόν τον host, αλλά οι άλλοι hosts θα συνεχίσουν να απαιτούν έγκυρα πιστοποιητικά."
},
"TEXT_IMPORT": {
"LABEL": "Προσθήκη m3u(8) λίστας αναπαραγωγής ως κείμενο",
"BUTTON_LABEL": "Εισαγωγή"
"LABEL": "Προσθήκη m3u(8) λίστας αναπαραγωγής ως κείμενο"
},
"XTREAM_PLAYLIST": {
"TITLE": "Τίτλος λίστας αναπαραγωγής",
"SERVER_URL": "Διεύθηνση URL σέρβερ",
"USERNAME": "Όνομα χρήστη",
"PASSWORD": "Κωδικός πρόσβασης",
"ADD": "Προσθήκη",
"TEST_CREDENTIALS": "Δοκιμή διαπιστευτήριων",
"URL_VALIDATION_ERROR": "Θα πρέπει να είναι μια έγκυρη διεύθυνση URL με πρωτόκολλο (π.χ. http://example.com ή https://example.com:4324)",
"URL_VALIDATION_ERROR": "Εισαγάγετε μια έγκυρη διεύθυνση URL που αρχίζει με http:// ή https://.",
"URL_HINT": "Διεύθυνση διακομιστή, π.χ. http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Εισαγάγετε όνομα χρήστη και κωδικό πρόσβασης για να ελέγξετε τη σύνδεση.",
"TESTING": "Έλεγχος σύνδεσης…",
@@ -199,8 +200,7 @@
}
},
"STALKER_PORTAL": {
"TITLE": "Τίτλος",
"MAC_ADDRESS": "Mac Address",
"MAC_ADDRESS": "Διεύθυνση MAC",
"MAC_ADDRESS_HINT": "Μορφή 00:1A:79:XX:XX:XX. Οι παύλες, οι τελείες και τα πεζά γράμματα διορθώνονται αυτόματα.",
"MAC_ADDRESS_OUI_HINT": "Αυτή η διεύθυνση είναι εκτός του εύρους 00:1A:79 της Infomir. Οι περισσότερες πύλες δέχονται μόνο αυτό το εύρος και απορρίπτουν οτιδήποτε άλλο χωρίς να εξηγούν τον λόγο.",
"MAC_ADDRESS_ERROR": "Εισαγάγετε μια διεύθυνση MAC 12 δεκαεξαδικών ψηφίων, για παράδειγμα 00:1A:79:12:34:56.",
@@ -218,8 +218,9 @@
"SERVER_URL": "Διεύθηνση URL σέρβερ",
"URL_HINT": "Εισαγάγετε έναν host ή μια διεύθυνση που τελειώνει σε /c, portal.php ή server/load.php.",
"URL_VALIDATION_ERROR": "Εισαγάγετε μια έγκυρη διεύθυνση URL που αρχίζει με http:// ή https://.",
"ADD": "Προσθήκη",
"VALIDATING": "Επικύρωση πύλης...",
"VALIDATING": "Επικύρωση πύλης…",
"VALIDATED_EXPIRES": "Η πύλη επικυρώθηκε. Λήγει στις {{date}}.",
"ADDED_WITHOUT_VALIDATION": "Η πύλη δεν απάντησε. Προστέθηκε χωρίς επικύρωση.",
"CREDENTIALS_HINT": "Απαιτείται μόνο όταν η πύλη ζητά όνομα χρήστη και κωδικό πρόσβασης",
"AUTH_FAILED": "Η ταυτοποίηση με την πύλη απέτυχε. Ελέγξτε τη διεύθυνση URL και τη διεύθυνση MAC.",
"EDIT_UNREACHABLE": "Η πύλη δεν απάντησε. Δεν αποθηκεύτηκαν αλλαγές. Ελέγξτε τη διεύθυνση URL και δοκιμάστε ξανά.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "Η πύλη έχει καταχωρημένο διαφορετικό αναγνωριστικό συσκευής για αυτήν τη διεύθυνση MAC.",
"PORTAL_MESSAGE": "Η πύλη ανέφερε: {{message}}"
},
"SHOW_PASSWORD": "Εμφάνιση κωδικού πρόσβασης",
"FILTER_BY_NAME": "Φιλτράρισμα κατά όνομα",
"FILTER_AND_SORT": "Φιλτράρισμα & Ταξινόμηση",
"SORT_PLAYLISTS": "Ταξινόμηση λιστών αναπαραγωγής",
+11 -9
View File
@@ -86,7 +86,7 @@
"SERVER_URL": "Server URL",
"USERNAME": "Username",
"PASSWORD": "Password",
"MAC_ADDRESS": "Mac Address",
"MAC_ADDRESS": "MAC address",
"PLAYLIST_EXPORT_SUCCESS": "Playlist was exported successfully.",
"PLAYLIST_EXPORT_ERROR": "Error: Playlist export failed.",
"COPY_URL": "Copy URL to clipboard",
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Pick how you want to connect. You can rename it later.",
"METHOD_GROUP_LABEL": "Connection method",
"METHOD_URL_LABEL": "M3U URL",
"METHOD_URL_SUB": "Paste a link to a remote .m3u or .m3u8",
"METHOD_FILE_LABEL": "M3U file",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Playlist URL (m3u, m3u8)",
"URL_VALIDATION_ERROR": "Enter a valid http:// or https:// URL.",
"ADD_PLAYLIST": "Add playlist",
"CORS_NOTE": "Note: In order to avoid CORS issues at this point the application uses a public CORS Proxy service. If you want to upload a playlist with sensitive data, it's more secure to import it from a file.",
"ERROR_FETCH_FAILED": "Failed to fetch the playlist. Please check the URL and try again.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates."
},
"TEXT_IMPORT": {
"LABEL": "Insert m3u(8) playlist as text",
"BUTTON_LABEL": "Import"
"LABEL": "Insert m3u(8) playlist as text"
},
"XTREAM_PLAYLIST": {
"TITLE": "Playlist title",
"SERVER_URL": "Server URL",
"USERNAME": "Username",
"PASSWORD": "Password",
"ADD": "Add",
"TEST_CREDENTIALS": "Test credentials",
"URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com or https://example.com:4324)",
"URL_VALIDATION_ERROR": "Enter a valid http:// or https:// URL.",
"URL_HINT": "Server address, e.g. http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Enter a username and password to test the connection.",
"TESTING": "Testing connection…",
@@ -199,8 +200,7 @@
}
},
"STALKER_PORTAL": {
"TITLE": "Title",
"MAC_ADDRESS": "Mac Address",
"MAC_ADDRESS": "MAC address",
"MAC_ADDRESS_HINT": "Format 00:1A:79:XX:XX:XX. Hyphens, dots and lowercase are corrected for you.",
"MAC_ADDRESS_OUI_HINT": "This address is outside Infomir's 00:1A:79 range. Most portals only accept that range and reject anything else without saying why.",
"MAC_ADDRESS_ERROR": "Enter a 12-digit MAC address, for example 00:1A:79:12:34:56.",
@@ -218,8 +218,9 @@
"SERVER_URL": "Server URL",
"URL_HINT": "Enter a host or an address ending in /c, portal.php, or server/load.php.",
"URL_VALIDATION_ERROR": "Enter a valid http:// or https:// URL.",
"ADD": "Add",
"VALIDATING": "Validating portal...",
"VALIDATING": "Validating portal…",
"VALIDATED_EXPIRES": "Portal validated. Expires {{date}}.",
"ADDED_WITHOUT_VALIDATION": "The portal did not respond. It was added without validation.",
"CREDENTIALS_HINT": "Only needed when the portal asks for a login and password",
"AUTH_FAILED": "Failed to authenticate with the portal. Check the URL and MAC address.",
"EDIT_UNREACHABLE": "The portal did not respond. No changes were saved. Check the URL and try again.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "The portal has a different device ID registered for this MAC address.",
"PORTAL_MESSAGE": "The portal reported: {{message}}"
},
"SHOW_PASSWORD": "Show password",
"FILTER_BY_NAME": "Filter by name",
"FILTER_AND_SORT": "Filter & Sort",
"SORT_PLAYLISTS": "Sort playlists",
+9 -7
View File
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Elige cómo quieres conectar. Puedes renombrarla más tarde.",
"METHOD_GROUP_LABEL": "Método de conexión",
"METHOD_URL_LABEL": "URL de M3U",
"METHOD_URL_SUB": "Pega un enlace a un archivo .m3u o .m3u8 remoto",
"METHOD_FILE_LABEL": "Archivo M3U",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL de la lista de reproducción (m3u, m3u8)",
"URL_VALIDATION_ERROR": "Introduce una URL válida que empiece por http:// o https://.",
"ADD_PLAYLIST": "Añadir lista",
"CORS_NOTE": "Nota: para evitar problemas de CORS, la aplicación usa un servicio público de proxy CORS. Si quieres cargar una lista de reproducción con datos sensibles, es más seguro importarla desde un archivo.",
"ERROR_FETCH_FAILED": "No se pudo obtener la lista de reproducción. Verifica la URL e inténtalo de nuevo.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "Continúa solo si confías en este host de la lista de reproducción. IPTVnator permitirá certificados TLS no válidos para este host, pero seguirá exigiendo certificados válidos para los demás."
},
"TEXT_IMPORT": {
"LABEL": "Inserte la playlist m3u(8) como texto",
"BUTTON_LABEL": "Importar"
"LABEL": "Inserte la playlist m3u(8) como texto"
},
"XTREAM_PLAYLIST": {
"TITLE": "Título de la lista",
"SERVER_URL": "URL del servidor",
"USERNAME": "Nombre de usuario",
"PASSWORD": "Contraseña",
"ADD": "Agregar",
"TEST_CREDENTIALS": "Probar credenciales",
"URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com o https://example.com:4324).",
"URL_VALIDATION_ERROR": "Introduce una URL válida que empiece por http:// o https://.",
"URL_HINT": "Dirección del servidor, por ejemplo http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Introduce un nombre de usuario y una contraseña para probar la conexión.",
"TESTING": "Probando conexión…",
@@ -199,8 +200,7 @@
}
},
"STALKER_PORTAL": {
"TITLE": "Título",
"MAC_ADDRESS": "Dirección Mac",
"MAC_ADDRESS": "Dirección MAC",
"MAC_ADDRESS_HINT": "Formato 00:1A:79:XX:XX:XX. Los guiones, los puntos y las minúsculas se corrigen automáticamente.",
"MAC_ADDRESS_OUI_HINT": "Esta dirección está fuera del rango 00:1A:79 de Infomir. La mayoría de los portales solo aceptan ese rango y rechazan cualquier otro sin indicar el motivo.",
"MAC_ADDRESS_ERROR": "Ingresa una dirección MAC de 12 dígitos, por ejemplo 00:1A:79:12:34:56.",
@@ -218,8 +218,9 @@
"SERVER_URL": "URL del servidor",
"URL_HINT": "Introduce un host o una dirección que termine en /c, portal.php o server/load.php.",
"URL_VALIDATION_ERROR": "Introduce una URL válida que empiece por http:// o https://.",
"ADD": "Agregar",
"VALIDATING": "Validando portal…",
"VALIDATED_EXPIRES": "Portal validado. Caduca el {{date}}.",
"ADDED_WITHOUT_VALIDATION": "El portal no respondió. Se agregó sin validación.",
"CREDENTIALS_HINT": "Solo es necesario cuando el portal pide usuario y contraseña",
"AUTH_FAILED": "No se pudo autenticar con el portal. Verifica la URL y la dirección MAC.",
"EDIT_UNREACHABLE": "El portal no respondió. No se guardaron los cambios. Comprueba la URL e inténtalo de nuevo.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "El portal tiene registrado un ID de dispositivo distinto para esta dirección MAC.",
"PORTAL_MESSAGE": "El portal informó: {{message}}"
},
"SHOW_PASSWORD": "Mostrar contraseña",
"FILTER_BY_NAME": "Filtrar por nombre",
"FILTER_AND_SORT": "Filtrar y ordenar",
"SORT_PLAYLISTS": "Ordenar listas",
+10 -8
View File
@@ -86,7 +86,7 @@
"SERVER_URL": "URL du serveur",
"USERNAME": "Nom d'utilisateur",
"PASSWORD": "Mot de passe",
"MAC_ADDRESS": "Adresse Mac",
"MAC_ADDRESS": "Adresse MAC",
"PLAYLIST_EXPORT_SUCCESS": "La liste de lecture a été exportée avec succès.",
"PLAYLIST_EXPORT_ERROR": "Erreur : Échec de l'exportation de la liste de lecture.",
"COPY_URL": "Copier l'URL dans le presse-papiers",
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Choisissez comment vous souhaitez vous connecter. Vous pourrez renommer la liste plus tard.",
"METHOD_GROUP_LABEL": "Méthode de connexion",
"METHOD_URL_LABEL": "URL M3U",
"METHOD_URL_SUB": "Collez un lien vers un fichier .m3u ou .m3u8 distant",
"METHOD_FILE_LABEL": "Fichier M3U",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL de la playlist (m3u, m3u8)",
"URL_VALIDATION_ERROR": "Saisissez une URL valide commençant par http:// ou https://.",
"ADD_PLAYLIST": "Ajouter la liste de lecture",
"CORS_NOTE": "Remarque : Pour éviter les problèmes CORS, l'application utilise un service proxy CORS public. Si vous souhaitez télécharger une liste avec des données sensibles, il est plus sûr de l'importer depuis un fichier.",
"ERROR_FETCH_FAILED": "Échec de la récupération de la liste de lecture. Veuillez vérifier l'URL et réessayer.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "Continuez uniquement si vous faites confiance à cet hôte de playlist. IPTVnator autorisera les certificats TLS invalides pour cet hôte, mais les autres hôtes devront toujours présenter des certificats valides."
},
"TEXT_IMPORT": {
"LABEL": "Insérer la liste m3u(8) sous forme de texte",
"BUTTON_LABEL": "Importer"
"LABEL": "Insérer la liste m3u(8) sous forme de texte"
},
"XTREAM_PLAYLIST": {
"TITLE": "Titre de la liste",
"SERVER_URL": "URL du serveur",
"USERNAME": "Nom d'utilisateur",
"PASSWORD": "Mot de passe",
"ADD": "Ajouter",
"TEST_CREDENTIALS": "Tester les identifiants",
"URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com ou https://example.com:4324)",
"URL_VALIDATION_ERROR": "Saisissez une URL valide commençant par http:// ou https://.",
"URL_HINT": "Adresse du serveur, par exemple http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Saisissez un nom d’utilisateur et un mot de passe pour tester la connexion.",
"TESTING": "Test de la connexion…",
@@ -199,8 +200,7 @@
}
},
"STALKER_PORTAL": {
"TITLE": "Titre",
"MAC_ADDRESS": "Adresse Mac",
"MAC_ADDRESS": "Adresse MAC",
"MAC_ADDRESS_HINT": "Format 00:1A:79:XX:XX:XX. Les tirets, les points et les minuscules sont corrigés automatiquement.",
"MAC_ADDRESS_OUI_HINT": "Cette adresse ne fait pas partie de la plage 00:1A:79 d'Infomir. La plupart des portails n'acceptent que cette plage et rejettent toute autre adresse sans en indiquer la raison.",
"MAC_ADDRESS_ERROR": "Saisissez une adresse MAC de 12 caractères hexadécimaux, par exemple 00:1A:79:12:34:56.",
@@ -218,8 +218,9 @@
"SERVER_URL": "URL du serveur",
"URL_HINT": "Saisissez un hôte ou une adresse se terminant par /c, portal.php ou server/load.php.",
"URL_VALIDATION_ERROR": "Saisissez une URL valide commençant par http:// ou https://.",
"ADD": "Ajouter",
"VALIDATING": "Validation du portail…",
"VALIDATED_EXPIRES": "Portail validé. Expire le {{date}}.",
"ADDED_WITHOUT_VALIDATION": "Le portail n'a pas répondu. Il a été ajouté sans validation.",
"CREDENTIALS_HINT": "Nécessaire uniquement lorsque le portail demande un identifiant et un mot de passe",
"AUTH_FAILED": "Échec de l'authentification auprès du portail. Vérifiez l'URL et l'adresse MAC.",
"EDIT_UNREACHABLE": "Le portail n'a pas répondu. Aucune modification n'a été enregistrée. Vérifiez l'URL et réessayez.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "Le portail a enregistré un autre ID d'appareil pour cette adresse MAC.",
"PORTAL_MESSAGE": "Le portail a signalé : {{message}}"
},
"SHOW_PASSWORD": "Afficher le mot de passe",
"FILTER_BY_NAME": "Filtrer par nom",
"FILTER_AND_SORT": "Filtrer et trier",
"SORT_PLAYLISTS": "Trier les listes",
+8 -6
View File
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Válassza ki, hogyan szeretne csatlakozni. A megnevezést később módosíthatja.",
"METHOD_GROUP_LABEL": "Csatlakozási mód",
"METHOD_URL_LABEL": "M3U URL-címe",
"METHOD_URL_SUB": "Illesszen be egy távoli .m3u- vagy .m3u8-fájlra mutató hivatkozást",
"METHOD_FILE_LABEL": "M3U-fájl",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Lejátszási lista URL-címe (m3u, m3u8)",
"URL_VALIDATION_ERROR": "Adjon meg egy http:// vagy https:// kezdetű érvényes URL-címet.",
"ADD_PLAYLIST": "Lejátszási lista hozzáadása",
"CORS_NOTE": "Megjegyzés: a CORS-problémák elkerülésére az alkalmazás jelenleg nyilvános CORS-proxyszolgáltatást használ. Ha a lejátszási lista bizalmas adatokat tartalmaz, biztonságosabb fájlból importálni.",
"ERROR_FETCH_FAILED": "Nem sikerült lekérni a lejátszási listát. Ellenőrizze az URL-címet, majd próbálja újra.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "Csak akkor folytassa, ha megbízik a lejátszási lista kiszolgálójában. Az IPTVnator ennél a kiszolgálónál engedélyezi az érvénytelen TLS-tanúsítványokat, más kiszolgálóknál azonban továbbra is érvényes tanúsítványt követel meg."
},
"TEXT_IMPORT": {
"LABEL": "M3U/M3U8-lejátszási lista beillesztése szövegként",
"BUTTON_LABEL": "Importálás"
"LABEL": "M3U/M3U8-lejátszási lista beillesztése szövegként"
},
"XTREAM_PLAYLIST": {
"TITLE": "Lejátszási lista címe",
"SERVER_URL": "Kiszolgáló URL-címe",
"USERNAME": "Felhasználónév",
"PASSWORD": "Jelszó",
"ADD": "Hozzáadás",
"TEST_CREDENTIALS": "Hitelesítési adatok ellenőrzése",
"URL_VALIDATION_ERROR": "Adjon meg protokollt is tartalmazó, érvényes URL-címet (például http://example.com vagy https://example.com:4324)",
"URL_VALIDATION_ERROR": "Adjon meg egy http:// vagy https:// kezdetű érvényes URL-címet.",
"URL_HINT": "Kiszolgáló címe, pl. http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "A kapcsolat teszteléséhez adjon meg egy felhasználónevet és jelszót.",
"TESTING": "Kapcsolat tesztelése…",
@@ -199,7 +200,6 @@
}
},
"STALKER_PORTAL": {
"TITLE": "Cím",
"MAC_ADDRESS": "MAC-cím",
"MAC_ADDRESS_HINT": "Formátum: 00:1A:79:XX:XX:XX. A kötőjeleket, a pontokat és a kisbetűket az alkalmazás automatikusan kijavítja.",
"MAC_ADDRESS_OUI_HINT": "Ez a cím az Infomir 00:1A:79 tartományán kívül esik. A legtöbb portál csak ezt a tartományt fogadja el, minden mást indoklás nélkül elutasít.",
@@ -218,8 +218,9 @@
"SERVER_URL": "Kiszolgáló URL-címe",
"URL_HINT": "Adjon meg egy gazdagépet vagy /c, portal.php vagy server/load.php végű címet.",
"URL_VALIDATION_ERROR": "Adjon meg egy http:// vagy https:// kezdetű érvényes URL-címet.",
"ADD": "Hozzáadás",
"VALIDATING": "Portál ellenőrzése…",
"VALIDATED_EXPIRES": "A portál ellenőrzése sikerült. Lejárat: {{date}}.",
"ADDED_WITHOUT_VALIDATION": "A portál nem válaszolt. Ellenőrzés nélkül lett hozzáadva.",
"CREDENTIALS_HINT": "Csak akkor szükséges, ha a portál felhasználónevet és jelszót kér",
"AUTH_FAILED": "Nem sikerült a hitelesítés a portálon. Ellenőrizze az URL-címet és a MAC-címet.",
"EDIT_UNREACHABLE": "A portál nem válaszolt. A módosítások nem lettek mentve. Ellenőrizze az URL-címet, és próbálja újra.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "A portálon más eszközazonosító van regisztrálva ehhez a MAC-címhez.",
"PORTAL_MESSAGE": "A portál a következőt jelezte: {{message}}"
},
"SHOW_PASSWORD": "Jelszó megjelenítése",
"FILTER_BY_NAME": "Szűrés név alapján",
"FILTER_AND_SORT": "Szűrés és rendezés",
"SORT_PLAYLISTS": "Lejátszási listák rendezése",
+9 -7
View File
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Scegli come connetterti. Puoi rinominarla in seguito.",
"METHOD_GROUP_LABEL": "Metodo di connessione",
"METHOD_URL_LABEL": "URL M3U",
"METHOD_URL_SUB": "Incolla un link a un file .m3u o .m3u8 remoto",
"METHOD_FILE_LABEL": "File M3U",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL playlist (m3u, m3u8)",
"URL_VALIDATION_ERROR": "Inserisci un URL valido che inizi con http:// o https://.",
"ADD_PLAYLIST": "Aggiungi playlist",
"CORS_NOTE": "Nota: per evitare problemi CORS a questo punto, l'applicazione usa un servizio proxy CORS pubblico. Se vuoi caricare una playlist con dati sensibili, è meglio importarla come file nella scheda adiacente.",
"ERROR_FETCH_FAILED": "Impossibile recuperare la playlist. Controlla l'URL e riprova.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "Continua solo se ti fidi di questo host della playlist. IPTVnator consentirà certificati TLS non validi per questo host, ma gli altri host richiederanno comunque certificati validi."
},
"TEXT_IMPORT": {
"LABEL": "Inserisci playlist m3u(8) come testo",
"BUTTON_LABEL": "Importa"
"LABEL": "Inserisci playlist m3u(8) come testo"
},
"XTREAM_PLAYLIST": {
"TITLE": "Titolo playlist",
"SERVER_URL": "URL server",
"USERNAME": "Utente",
"PASSWORD": "Password",
"ADD": "Aggiungi",
"TEST_CREDENTIALS": "Testa credenziali",
"URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it o https://esempio.it:4324)",
"URL_VALIDATION_ERROR": "Inserisci un URL valido che inizi con http:// o https://.",
"URL_HINT": "Indirizzo del server, es. http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Inserisci nome utente e password per verificare la connessione.",
"TESTING": "Verifica della connessione…",
@@ -199,7 +200,6 @@
}
},
"STALKER_PORTAL": {
"TITLE": "Titolo",
"MAC_ADDRESS": "Indirizzo MAC",
"MAC_ADDRESS_HINT": "Formato 00:1A:79:XX:XX:XX. Trattini, punti e lettere minuscole vengono corretti automaticamente.",
"MAC_ADDRESS_OUI_HINT": "Questo indirizzo non rientra nell'intervallo 00:1A:79 di Infomir. La maggior parte dei portali accetta solo quell'intervallo e rifiuta tutto il resto senza spiegarne il motivo.",
@@ -218,8 +218,9 @@
"SERVER_URL": "URL server",
"URL_HINT": "Inserisci un host o un indirizzo che termini con /c, portal.php o server/load.php.",
"URL_VALIDATION_ERROR": "Inserisci un URL valido che inizi con http:// o https://.",
"ADD": "Aggiungi",
"VALIDATING": "Validazione del portale...",
"VALIDATING": "Validazione del portale…",
"VALIDATED_EXPIRES": "Portale validato. Scade il {{date}}.",
"ADDED_WITHOUT_VALIDATION": "Il portale non ha risposto. È stato aggiunto senza validazione.",
"CREDENTIALS_HINT": "Necessario solo se il portale richiede nome utente e password",
"AUTH_FAILED": "Impossibile autenticarsi con il portale. Controlla l'URL e l'indirizzo MAC.",
"EDIT_UNREACHABLE": "Il portale non ha risposto. Nessuna modifica è stata salvata. Controlla l'URL e riprova.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "Il portale ha registrato un ID dispositivo diverso per questo indirizzo MAC.",
"PORTAL_MESSAGE": "Il portale ha segnalato: {{message}}"
},
"SHOW_PASSWORD": "Mostra password",
"FILTER_BY_NAME": "Filtra per nome",
"FILTER_AND_SORT": "Filtra e ordina",
"SORT_PLAYLISTS": "Ordina playlist",
+9 -7
View File
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "接続方法を選択してください。名前は後から変更できます。",
"METHOD_GROUP_LABEL": "接続方法",
"METHOD_URL_LABEL": "M3U URL",
"METHOD_URL_SUB": "リモートの.m3uまたは.m3u8へのリンクを貼り付け",
"METHOD_FILE_LABEL": "M3Uファイル",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "プレイリスト URL(m3u、m3u8)",
"URL_VALIDATION_ERROR": "http:// または https:// で始まる有効なURLを入力してください。",
"ADD_PLAYLIST": "プレイリストを追加",
"CORS_NOTE": "注意:CORSの問題を回避するため、現時点ではパブリックCORSプロキシサービスを使用しています。機密データを含むプレイリストをアップロードする場合は、ファイルからインポートする方が安全です。",
"ERROR_FETCH_FAILED": "プレイリストを取得できませんでした。URLを確認してもう一度お試しください。",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "このプレイリストホストを信頼できる場合のみ続行してください。IPTVnator はこのホストの無効な TLS 証明書を許可しますが、他のホストには引き続き有効な証明書が必要です。"
},
"TEXT_IMPORT": {
"LABEL": "m3u(8)プレイリストをテキストとして挿入",
"BUTTON_LABEL": "インポート"
"LABEL": "m3u(8)プレイリストをテキストとして挿入"
},
"XTREAM_PLAYLIST": {
"TITLE": "プレイリストタイトル",
"SERVER_URL": "サーバーURL",
"USERNAME": "ユーザー名",
"PASSWORD": "パスワード",
"ADD": "追加",
"TEST_CREDENTIALS": "認証情報をテスト",
"URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com または https://example.com:4324)",
"URL_VALIDATION_ERROR": "http:// または https:// で始まる有効なURLを入力してください。",
"URL_HINT": "サーバーアドレス(例:http://host:port)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "接続をテストするには、ユーザー名とパスワードを入力してください。",
"TESTING": "接続をテスト中…",
@@ -199,7 +200,6 @@
}
},
"STALKER_PORTAL": {
"TITLE": "タイトル",
"MAC_ADDRESS": "MACアドレス",
"MAC_ADDRESS_HINT": "形式は00:1A:79:XX:XX:XXです。ハイフンやドット、小文字は自動的に修正されます。",
"MAC_ADDRESS_OUI_HINT": "このアドレスはInfomirの00:1A:79の範囲外です。ほとんどのポータルはこの範囲のみを受け付け、それ以外は理由を示さずに拒否します。",
@@ -218,8 +218,9 @@
"SERVER_URL": "サーバーURL",
"URL_HINT": "ホスト、または /c、portal.php、server/load.php で終わるアドレスを入力してください。",
"URL_VALIDATION_ERROR": "http:// または https:// で始まる有効なURLを入力してください。",
"ADD": "追加",
"VALIDATING": "ポータルを検証中...",
"VALIDATING": "ポータルを検証中…",
"VALIDATED_EXPIRES": "ポータルを検証しました。有効期限は{{date}}です。",
"ADDED_WITHOUT_VALIDATION": "ポータルが応答しませんでした。検証せずに追加しました。",
"CREDENTIALS_HINT": "ポータルがユーザー名とパスワードを要求する場合のみ必要",
"AUTH_FAILED": "ポータルでの認証に失敗しました。URLとMACアドレスを確認してください。",
"EDIT_UNREACHABLE": "ポータルが応答しませんでした。変更は保存されていません。URLを確認して再試行してください。",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "このMACアドレスには、別のデバイスIDがポータルに登録されています。",
"PORTAL_MESSAGE": "ポータルからの報告:{{message}}"
},
"SHOW_PASSWORD": "パスワードを表示",
"FILTER_BY_NAME": "名前でフィルター",
"FILTER_AND_SORT": "フィルターと並び替え",
"SORT_PLAYLISTS": "プレイリストを並び替え",
+10 -8
View File
@@ -86,7 +86,7 @@
"SERVER_URL": "서버 URL",
"USERNAME": "사용자 이름",
"PASSWORD": "비밀번호",
"MAC_ADDRESS": "맥 주소",
"MAC_ADDRESS": "MAC 주소",
"PLAYLIST_EXPORT_SUCCESS": "재생목록을 성공적으로 내보냈습니다.",
"PLAYLIST_EXPORT_ERROR": "오류: 재생목록 내보내기에 실패했습니다.",
"COPY_URL": "URL을 클립보드에 복사",
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "연결 방식을 선택하세요. 이름은 나중에 변경할 수 있습니다.",
"METHOD_GROUP_LABEL": "연결 방식",
"METHOD_URL_LABEL": "M3U URL",
"METHOD_URL_SUB": "원격 .m3u 또는 .m3u8 링크를 붙여넣으세요",
"METHOD_FILE_LABEL": "M3U 파일",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "플레이리스트 URL(m3u, m3u8)",
"URL_VALIDATION_ERROR": "http:// 또는 https://로 시작하는 올바른 URL을 입력하세요.",
"ADD_PLAYLIST": "재생 목록을 추가하십시오",
"CORS_NOTE": "참고: CORS 문제를 피하기 위해 현재 애플리케이션은 공용 CORS 프록시 서비스를 사용합니다. 민감한 데이터가 포함된 재생목록을 업로드하려면 파일에서 가져오는 것이 더 안전합니다.",
"ERROR_FETCH_FAILED": "재생목록을 가져오지 못했습니다. URL을 확인하고 다시 시도해 주세요.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "이 플레이리스트 호스트를 신뢰하는 경우에만 계속하세요. IPTVnator는 이 호스트의 유효하지 않은 TLS 인증서를 허용하지만, 다른 호스트에는 계속 유효한 인증서가 필요합니다."
},
"TEXT_IMPORT": {
"LABEL": "Insert m3u(8) playlist as text",
"BUTTON_LABEL": "Import"
"LABEL": "Insert m3u(8) playlist as text"
},
"XTREAM_PLAYLIST": {
"TITLE": "재생목록 제목",
"SERVER_URL": "서버 URL",
"USERNAME": "사용자 이름",
"PASSWORD": "비밀번호",
"ADD": "추가",
"TEST_CREDENTIALS": "자격 증명 테스트",
"URL_VALIDATION_ERROR": "프로토콜이 포함된 유효한 URL이어야 합니다(예: http://example.com 또는 https://example.com:4324).",
"URL_VALIDATION_ERROR": "http:// 또는 https://로 시작하는 올바른 URL을 입력하세요.",
"URL_HINT": "서버 주소(예: http://host:port)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "연결을 테스트하려면 사용자 이름과 비밀번호를 입력하세요.",
"TESTING": "연결 테스트 중…",
@@ -199,7 +200,6 @@
}
},
"STALKER_PORTAL": {
"TITLE": "제목",
"MAC_ADDRESS": "MAC 주소",
"MAC_ADDRESS_HINT": "형식: 00:1A:79:XX:XX:XX. 하이픈, 점, 소문자는 자동으로 수정됩니다.",
"MAC_ADDRESS_OUI_HINT": "이 주소는 Infomir의 00:1A:79 범위를 벗어납니다. 대부분의 포털은 해당 범위만 허용하며, 그 외의 주소는 이유를 알리지 않고 거부합니다.",
@@ -218,8 +218,9 @@
"SERVER_URL": "서버 URL",
"URL_HINT": "호스트 또는 /c, portal.php, server/load.php로 끝나는 주소를 입력하세요.",
"URL_VALIDATION_ERROR": "http:// 또는 https://로 시작하는 올바른 URL을 입력하세요.",
"ADD": "추가하다",
"VALIDATING": "포털을 검증하는 중...",
"VALIDATING": "포털을 검증하는 중…",
"VALIDATED_EXPIRES": "포털이 검증되었습니다. {{date}}에 만료됩니다.",
"ADDED_WITHOUT_VALIDATION": "포털이 응답하지 않았습니다. 검증 없이 추가되었습니다.",
"CREDENTIALS_HINT": "포털이 사용자 이름과 비밀번호를 요구하는 경우에만 필요",
"AUTH_FAILED": "포털 인증에 실패했습니다. URL과 MAC 주소를 확인하세요.",
"EDIT_UNREACHABLE": "포털이 응답하지 않았습니다. 변경 사항이 저장되지 않았습니다. URL을 확인하고 다시 시도하세요.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "포털에 이 MAC 주소로 다른 기기 ID가 등록되어 있습니다.",
"PORTAL_MESSAGE": "포털에서 보고한 내용: {{message}}"
},
"SHOW_PASSWORD": "비밀번호 표시",
"FILTER_BY_NAME": "이름으로 필터",
"FILTER_AND_SORT": "필터 및 정렬",
"SORT_PLAYLISTS": "재생목록 정렬",
+10 -8
View File
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Kies hoe je verbinding wilt maken. Je kunt het later hernoemen.",
"METHOD_GROUP_LABEL": "Verbindingsmethode",
"METHOD_URL_LABEL": "M3U-URL",
"METHOD_URL_SUB": "Plak een link naar een externe .m3u of .m3u8",
"METHOD_FILE_LABEL": "M3U-bestand",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Afspeellijst-URL (m3u, m3u8)",
"URL_VALIDATION_ERROR": "Voer een geldige URL in die begint met http:// of https://.",
"ADD_PLAYLIST": "Afspeellijst toevoegen",
"CORS_NOTE": "Opmerking: om CORS-problemen te voorkomen, gebruikt de app een openbare CORS-proxyservice. Voor gevoelige gegevens is het veiliger om de afspeellijst via een bestand te importeren.",
"ERROR_FETCH_FAILED": "Ophalen van afspeellijst mislukt. Controleer de URL en probeer het opnieuw.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "Ga alleen verder als u deze afspeellijsthost vertrouwt. IPTVnator staat ongeldige TLS-certificaten voor deze host toe, maar andere hosts moeten nog steeds geldige certificaten gebruiken."
},
"TEXT_IMPORT": {
"LABEL": "Voer een m3u(8)-afspeellijst in als tekst",
"BUTTON_LABEL": "Importeren"
"LABEL": "Voer een m3u(8)-afspeellijst in als tekst"
},
"XTREAM_PLAYLIST": {
"TITLE": "Afspeellijst titel",
"SERVER_URL": "Server URL",
"USERNAME": "Gebruikersnaam",
"PASSWORD": "Wachtwoord",
"ADD": "Toevoegen",
"TEST_CREDENTIALS": "Test de inloggegevens",
"URL_VALIDATION_ERROR": "Moet een geldige URL zijn met protocol (bijv. http://example.com of https://example.com:4324)",
"URL_VALIDATION_ERROR": "Voer een geldige URL in die begint met http:// of https://.",
"URL_HINT": "Serveradres, bijv. http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Voer een gebruikersnaam en wachtwoord in om de verbinding te testen.",
"TESTING": "Verbinding testen…",
@@ -199,8 +200,7 @@
}
},
"STALKER_PORTAL": {
"TITLE": "Titel",
"MAC_ADDRESS": "Mac adres",
"MAC_ADDRESS": "MAC-adres",
"MAC_ADDRESS_HINT": "Formaat 00:1A:79:XX:XX:XX. Streepjes, punten en kleine letters worden automatisch voor je gecorrigeerd.",
"MAC_ADDRESS_OUI_HINT": "Dit adres valt buiten het 00:1A:79-bereik van Infomir. De meeste portalen accepteren alleen dat bereik en weigeren al het andere zonder uitleg.",
"MAC_ADDRESS_ERROR": "Voer een MAC-adres van 12 hex-tekens in, bijvoorbeeld 00:1A:79:12:34:56.",
@@ -218,8 +218,9 @@
"SERVER_URL": "Server URL",
"URL_HINT": "Voer een host of adres in dat eindigt op /c, portal.php of server/load.php.",
"URL_VALIDATION_ERROR": "Voer een geldige URL in die begint met http:// of https://.",
"ADD": "Toevoegen",
"VALIDATING": "Portaal valideren...",
"VALIDATING": "Portaal valideren…",
"VALIDATED_EXPIRES": "Portaal gevalideerd. Verloopt op {{date}}.",
"ADDED_WITHOUT_VALIDATION": "Het portaal reageerde niet. Het is toegevoegd zonder validatie.",
"CREDENTIALS_HINT": "Alleen nodig als het portaal om een gebruikersnaam en wachtwoord vraagt",
"AUTH_FAILED": "Aanmelden bij het portaal is mislukt. Controleer de URL en het MAC-adres.",
"EDIT_UNREACHABLE": "Het portaal reageerde niet. Er zijn geen wijzigingen opgeslagen. Controleer de URL en probeer het opnieuw.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "Het portaal heeft een andere apparaat-ID geregistreerd voor dit MAC-adres.",
"PORTAL_MESSAGE": "Het portaal meldde: {{message}}"
},
"SHOW_PASSWORD": "Toon wachtwoord",
"FILTER_BY_NAME": "Filter op naam",
"FILTER_AND_SORT": "Filteren & sorteren",
"SORT_PLAYLISTS": "Sorteer afspeellijsten",
+11 -9
View File
@@ -86,7 +86,7 @@
"SERVER_URL": "URL serwera",
"USERNAME": "Nazwa użytkownika",
"PASSWORD": "Hasło",
"MAC_ADDRESS": "Adres Mac",
"MAC_ADDRESS": "Adres MAC",
"PLAYLIST_EXPORT_SUCCESS": "Lista odtwarzania została pomyślnie wyeksportowana.",
"PLAYLIST_EXPORT_ERROR": "Błąd: Eksport listy odtwarzania nie powiódł się.",
"COPY_URL": "Kopiuj URL do schowka",
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Wybierz sposób połączenia. Nazwę można zmienić później.",
"METHOD_GROUP_LABEL": "Sposób połączenia",
"METHOD_URL_LABEL": "Adres URL M3U",
"METHOD_URL_SUB": "Wklej link do zdalnego pliku .m3u lub .m3u8",
"METHOD_FILE_LABEL": "Plik M3U",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL listy odtwarzania (m3u, m3u8)",
"URL_VALIDATION_ERROR": "Wprowadź poprawny URL zaczynający się od http:// lub https://.",
"ADD_PLAYLIST": "Dodaj listę odtwarzania",
"CORS_NOTE": "Uwaga: Aby uniknąć problemów z CORS, aplikacja używa publicznej usługi proxy CORS. Jeśli chcesz przesłać listę odtwarzania z wrażliwymi danymi, bezpieczniej jest zaimportować ją z pliku.",
"ERROR_FETCH_FAILED": "Nie udało się pobrać listy odtwarzania. Sprawdź URL i spróbuj ponownie.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "Kontynuuj tylko wtedy, gdy ufasz temu hostowi listy odtwarzania. IPTVnator dopuści nieprawidłowe certyfikaty TLS dla tego hosta, ale inne hosty nadal będą wymagały prawidłowych certyfikatów."
},
"TEXT_IMPORT": {
"LABEL": "Wklej listę odtwarzania m3u(8) jako tekst",
"BUTTON_LABEL": "Importuj"
"LABEL": "Wklej listę odtwarzania m3u(8) jako tekst"
},
"XTREAM_PLAYLIST": {
"TITLE": "Tytuł listy odtwarzania",
"SERVER_URL": "URL serwera",
"USERNAME": "Nazwa użytkownika",
"PASSWORD": "Hasło",
"ADD": "Dodaj",
"TEST_CREDENTIALS": "Testuj dane uwierzytelniające",
"URL_VALIDATION_ERROR": "Powinien to być poprawny URL z protokołem (np. http://example.com lub https://example.com:4324)",
"URL_VALIDATION_ERROR": "Wprowadź poprawny URL zaczynający się od http:// lub https://.",
"URL_HINT": "Adres serwera, np. http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Wpisz nazwę użytkownika i hasło, aby sprawdzić połączenie.",
"TESTING": "Sprawdzanie połączenia…",
@@ -199,8 +200,7 @@
}
},
"STALKER_PORTAL": {
"TITLE": "Tytuł",
"MAC_ADDRESS": "Adres Mac",
"MAC_ADDRESS": "Adres MAC",
"MAC_ADDRESS_HINT": "Format 00:1A:79:XX:XX:XX. Myślniki, kropki i małe litery zostaną poprawione automatycznie.",
"MAC_ADDRESS_OUI_HINT": "Ten adres jest spoza zakresu 00:1A:79 firmy Infomir. Większość portali akceptuje tylko ten zakres i bez wyjaśnienia odrzuca wszystkie inne adresy.",
"MAC_ADDRESS_ERROR": "Wprowadź adres MAC złożony z 12 znaków szesnastkowych, na przykład 00:1A:79:12:34:56.",
@@ -218,8 +218,9 @@
"SERVER_URL": "URL serwera",
"URL_HINT": "Wprowadź host lub adres zakończony /c, portal.php albo server/load.php.",
"URL_VALIDATION_ERROR": "Wprowadź poprawny URL zaczynający się od http:// lub https://.",
"ADD": "Dodaj",
"VALIDATING": "Walidacja portalu...",
"VALIDATING": "Walidacja portalu…",
"VALIDATED_EXPIRES": "Portal został zwalidowany. Wygasa {{date}}.",
"ADDED_WITHOUT_VALIDATION": "Portal nie odpowiedział. Został dodany bez walidacji.",
"CREDENTIALS_HINT": "Potrzebne tylko wtedy, gdy portal wymaga loginu i hasła",
"AUTH_FAILED": "Nie udało się uwierzytelnić w portalu. Sprawdź URL i adres MAC.",
"EDIT_UNREACHABLE": "Portal nie odpowiedział. Nie zapisano żadnych zmian. Sprawdź URL i spróbuj ponownie.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "Portal ma zarejestrowane inne ID urządzenia dla tego adresu MAC.",
"PORTAL_MESSAGE": "Portal zgłosił: {{message}}"
},
"SHOW_PASSWORD": "Pokaż hasło",
"FILTER_BY_NAME": "Filtruj według nazwy",
"FILTER_AND_SORT": "Filtruj i sortuj",
"SORT_PLAYLISTS": "Sortuj listy odtwarzania",
+11 -9
View File
@@ -86,7 +86,7 @@
"SERVER_URL": "URL do servidor",
"USERNAME": "Nome de usuário",
"PASSWORD": "Senha",
"MAC_ADDRESS": "Endereço Mac",
"MAC_ADDRESS": "Endereço MAC",
"PLAYLIST_EXPORT_SUCCESS": "Playlist foi exportada com sucesso.",
"PLAYLIST_EXPORT_ERROR": "Erro: Falha ao exportar a playlist.",
"COPY_URL": "Copiar URL para a área de transferência",
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Escolha como você quer se conectar. Você pode renomeá-la depois.",
"METHOD_GROUP_LABEL": "Forma de conexão",
"METHOD_URL_LABEL": "URL M3U",
"METHOD_URL_SUB": "Cole um link para um arquivo .m3u ou .m3u8 remoto",
"METHOD_FILE_LABEL": "Arquivo M3U",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL da playlist (m3u, m3u8)",
"URL_VALIDATION_ERROR": "Introduza um URL válido que comece por http:// ou https://.",
"ADD_PLAYLIST": "Adicionar playlist",
"CORS_NOTE": "Nota: Para evitar problemas de CORS neste ponto, o aplicativo usa um serviço de Proxy CORS público. Se você deseja enviar uma playlist com dados confidenciais, é mais seguro importá-la de um arquivo.",
"ERROR_FETCH_FAILED": "Falha ao buscar a playlist. Verifique a URL e tente novamente.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "Continue somente se você confiar neste host da playlist. O IPTVnator permitirá certificados TLS inválidos para este host, mas outros hosts ainda exigem certificados válidos."
},
"TEXT_IMPORT": {
"LABEL": "Inserir playlist m3u(8) como texto",
"BUTTON_LABEL": "Importar"
"LABEL": "Inserir playlist m3u(8) como texto"
},
"XTREAM_PLAYLIST": {
"TITLE": "Título da playlist",
"SERVER_URL": "URL do servidor",
"USERNAME": "Nome de usuário",
"PASSWORD": "Senha",
"ADD": "Adicionar",
"TEST_CREDENTIALS": "Testar credenciais",
"URL_VALIDATION_ERROR": "Deve ser uma URL válida com protocolo (por exemplo, http://example.com ou https://example.com:4324)",
"URL_VALIDATION_ERROR": "Introduza um URL válido que comece por http:// ou https://.",
"URL_HINT": "Endereço do servidor, por exemplo, http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Introduza um nome de utilizador e uma palavra-passe para testar a ligação.",
"TESTING": "A testar ligação…",
@@ -199,8 +200,7 @@
}
},
"STALKER_PORTAL": {
"TITLE": "Título",
"MAC_ADDRESS": "Endereço Mac",
"MAC_ADDRESS": "Endereço MAC",
"MAC_ADDRESS_HINT": "Formato 00:1A:79:XX:XX:XX. Hífens, pontos e letras minúsculas são corrigidos automaticamente.",
"MAC_ADDRESS_OUI_HINT": "Este endereço está fora da faixa 00:1A:79 da Infomir. A maioria dos portais aceita apenas essa faixa e rejeita qualquer outra sem informar o motivo.",
"MAC_ADDRESS_ERROR": "Digite um endereço MAC de 12 dígitos, por exemplo 00:1A:79:12:34:56.",
@@ -218,8 +218,9 @@
"SERVER_URL": "URL do servidor",
"URL_HINT": "Introduza um host ou endereço terminado em /c, portal.php ou server/load.php.",
"URL_VALIDATION_ERROR": "Introduza um URL válido que comece por http:// ou https://.",
"ADD": "Adicionar",
"VALIDATING": "Validando portal...",
"VALIDATING": "Validando portal…",
"VALIDATED_EXPIRES": "Portal validado. Expira em {{date}}.",
"ADDED_WITHOUT_VALIDATION": "O portal não respondeu. Foi adicionado sem validação.",
"CREDENTIALS_HINT": "Necessário apenas quando o portal exige login e senha",
"AUTH_FAILED": "Falha na autenticação com o portal. Verifique a URL e o endereço MAC.",
"EDIT_UNREACHABLE": "O portal não respondeu. Nenhuma alteração foi guardada. Verifique o URL e tente novamente.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "O portal tem um ID de dispositivo diferente registrado para este endereço MAC.",
"PORTAL_MESSAGE": "O portal informou: {{message}}"
},
"SHOW_PASSWORD": "Mostrar senha",
"FILTER_BY_NAME": "Filtrar por nome",
"FILTER_AND_SORT": "Filtrar e ordenar",
"SORT_PLAYLISTS": "Ordenar playlists",
+8 -6
View File
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Выберите способ подключения. Переименовать плейлист можно позже.",
"METHOD_GROUP_LABEL": "Способ подключения",
"METHOD_URL_LABEL": "M3U URL",
"METHOD_URL_SUB": "Вставьте ссылку на удалённый файл .m3u или .m3u8",
"METHOD_FILE_LABEL": "Файл M3U",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL плейлиста (m3u, m3u8)",
"URL_VALIDATION_ERROR": "Введите корректный URL, начинающийся с http:// или https://.",
"ADD_PLAYLIST": "Добавить плейлист",
"CORS_NOTE": "Примечание: Чтобы избежать проблем с CORS, приложение использует публичный CORS-прокси сервис. Если вы хотите загрузить плейлист с конфиденциальными данными, безопаснее импортировать его из файла.",
"ERROR_FETCH_FAILED": "Не удалось получить плейлист. Проверьте URL и попробуйте снова.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "Продолжайте только если доверяете этому хосту плейлиста. IPTVnator разрешит недействительные TLS-сертификаты для этого хоста, но для остальных хостов по-прежнему потребуются действительные сертификаты."
},
"TEXT_IMPORT": {
"LABEL": "Вставьте плейлист формата m3u(8) в данное поле",
"BUTTON_LABEL": "Импортировать"
"LABEL": "Вставьте плейлист формата m3u(8) в данное поле"
},
"XTREAM_PLAYLIST": {
"TITLE": "Название плейлиста",
"SERVER_URL": "Сервер URL",
"USERNAME": "Пользователь",
"PASSWORD": "Пароль",
"ADD": "Добавить",
"TEST_CREDENTIALS": "Проверить",
"URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com или https://example.com:4324).",
"URL_VALIDATION_ERROR": "Введите корректный URL, начинающийся с http:// или https://.",
"URL_HINT": "Адрес сервера, например http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Введите имя пользователя и пароль для проверки подключения.",
"TESTING": "Проверка подключения…",
@@ -199,7 +200,6 @@
}
},
"STALKER_PORTAL": {
"TITLE": "Название",
"MAC_ADDRESS": "MAC-адрес",
"MAC_ADDRESS_HINT": "Формат 00:1A:79:XX:XX:XX. Дефисы, точки и строчные буквы исправляются автоматически.",
"MAC_ADDRESS_OUI_HINT": "Этот адрес находится вне диапазона 00:1A:79, зарегистрированного за Infomir. Большинство порталов принимают только этот диапазон и отклоняют остальные адреса, не объясняя причину.",
@@ -218,8 +218,9 @@
"SERVER_URL": "URL сервера",
"URL_HINT": "Введите хост или адрес, оканчивающийся на /c, portal.php или server/load.php.",
"URL_VALIDATION_ERROR": "Введите корректный URL, начинающийся с http:// или https://.",
"ADD": "Добавить",
"VALIDATING": "Проверка портала…",
"VALIDATED_EXPIRES": "Портал проверен. Истекает {{date}}.",
"ADDED_WITHOUT_VALIDATION": "Портал не ответил. Он добавлен без проверки.",
"CREDENTIALS_HINT": "Требуется, только если портал запрашивает логин и пароль",
"AUTH_FAILED": "Не удалось авторизоваться на портале. Проверьте URL и MAC-адрес.",
"EDIT_UNREACHABLE": "Портал не ответил. Изменения не сохранены. Проверьте URL и повторите попытку.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "Для этого MAC-адреса на портале зарегистрирован другой ID устройства.",
"PORTAL_MESSAGE": "Портал сообщил: {{message}}"
},
"SHOW_PASSWORD": "Показать пароль",
"FILTER_BY_NAME": "Фильтровать по имени",
"FILTER_AND_SORT": "Фильтр и сортировка",
"SORT_PLAYLISTS": "Сортировать плейлисты",
+11 -9
View File
@@ -86,7 +86,7 @@
"SERVER_URL": "Sunucu URL'si",
"USERNAME": "Kullanıcı Adı",
"PASSWORD": "Parola",
"MAC_ADDRESS": "MAC Adresi",
"MAC_ADDRESS": "MAC adresi",
"PLAYLIST_EXPORT_SUCCESS": "Oynatma listesi başarıyla dışa aktarıldı.",
"PLAYLIST_EXPORT_ERROR": "Hata: Oynatma listesi dışa aktarılamadı.",
"COPY_URL": "URL'yi panoya kopyala",
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "Nasıl bağlanmak istediğinizi seçin. Daha sonra yeniden adlandırabilirsiniz.",
"METHOD_GROUP_LABEL": "Bağlantı yöntemi",
"METHOD_URL_LABEL": "M3U URL",
"METHOD_URL_SUB": "Uzak bir .m3u veya .m3u8 bağlantısını yapıştırın",
"METHOD_FILE_LABEL": "M3U dosyası",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Oynatma listesi URL’si (m3u, m3u8)",
"URL_VALIDATION_ERROR": "http:// veya https:// ile başlayan geçerli bir URL girin.",
"ADD_PLAYLIST": "Oynatma listesi ekle",
"CORS_NOTE": "Not: CORS sorunlarını önlemek için bu aşamada uygulama, halka açık bir CORS Proxy hizmeti kullanır. Güvenlik nedeniyle hassas verileri içeren bir oynatma listesini yüklemek istiyorsanız, daha güvenli bir seçenek olarak dosyadan içe aktarmayı deneyin.",
"ERROR_FETCH_FAILED": "Oynatma listesi alınamadı. Lütfen URL'yi kontrol edip tekrar deneyin.",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "Yalnızca bu oynatma listesi ana bilgisayarına güveniyorsanız devam edin. IPTVnator bu ana bilgisayar için geçersiz TLS sertifikalarına izin verir, ancak diğer ana bilgisayarlar için geçerli sertifika gerekmeye devam eder."
},
"TEXT_IMPORT": {
"LABEL": "m3u(8) oynatma listesini metin olarak girin",
"BUTTON_LABEL": "İçe Aktar"
"LABEL": "m3u(8) oynatma listesini metin olarak girin"
},
"XTREAM_PLAYLIST": {
"TITLE": "Oynatma listesi başlığı",
"SERVER_URL": "Sunucu URL'si",
"USERNAME": "Kullanıcı Adı",
"PASSWORD": "Parola",
"ADD": "Ekle",
"TEST_CREDENTIALS": "Kimlik bilgilerini test et",
"URL_VALIDATION_ERROR": "Geçerli bir URL olmalıdır (örn. http://example.com veya https://example.com:4324)",
"URL_VALIDATION_ERROR": "http:// veya https:// ile başlayan geçerli bir URL girin.",
"URL_HINT": "Sunucu adresi, örn. http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Bağlantıyı test etmek için kullanıcı adı ve parola girin.",
"TESTING": "Bağlantı test ediliyor…",
@@ -199,8 +200,7 @@
}
},
"STALKER_PORTAL": {
"TITLE": "Başlık",
"MAC_ADDRESS": "MAC Adresi",
"MAC_ADDRESS": "MAC adresi",
"MAC_ADDRESS_HINT": "Biçim: 00:1A:79:XX:XX:XX. Tireler, noktalar ve küçük harfler sizin için düzeltilir.",
"MAC_ADDRESS_OUI_HINT": "Bu adres, Infomir'in 00:1A:79 aralığının dışında kalıyor. Çoğu portal yalnızca bu aralığı kabul eder ve diğer adresleri nedenini belirtmeden reddeder.",
"MAC_ADDRESS_ERROR": "12 haneli bir MAC adresi girin, örneğin 00:1A:79:12:34:56.",
@@ -218,8 +218,9 @@
"SERVER_URL": "Sunucu URL'si",
"URL_HINT": "/c, portal.php veya server/load.php ile biten bir ana makine ya da adres girin.",
"URL_VALIDATION_ERROR": "http:// veya https:// ile başlayan geçerli bir URL girin.",
"ADD": "Ekle",
"VALIDATING": "Portal doğrulanıyor...",
"VALIDATING": "Portal doğrulanıyor…",
"VALIDATED_EXPIRES": "Portal doğrulandı. Bitiş tarihi: {{date}}.",
"ADDED_WITHOUT_VALIDATION": "Portal yanıt vermedi. Doğrulama yapılmadan eklendi.",
"CREDENTIALS_HINT": "Yalnızca portal kullanıcı adı ve parola istediğinde gereklidir",
"AUTH_FAILED": "Portalda kimlik doğrulaması başarısız oldu. URL'yi ve MAC adresini kontrol edin.",
"EDIT_UNREACHABLE": "Portal yanıt vermedi. Hiçbir değişiklik kaydedilmedi. URL'yi kontrol edip tekrar deneyin.",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "Portalda bu MAC adresi için farklı bir cihaz kimliği kayıtlı.",
"PORTAL_MESSAGE": "Portal şunu bildirdi: {{message}}"
},
"SHOW_PASSWORD": "Parolayı göster",
"FILTER_BY_NAME": "İsime göre filtrele",
"FILTER_AND_SORT": "Filtrele ve Sırala",
"SORT_PLAYLISTS": "Oynatma listelerini sırala",
+9 -7
View File
@@ -86,7 +86,7 @@
"SERVER_URL": "服务器地址",
"USERNAME": "用户名",
"PASSWORD": "密码",
"MAC_ADDRESS": "MAC地址",
"MAC_ADDRESS": "MAC 地址",
"PLAYLIST_EXPORT_SUCCESS": "播放列表已成功导出。",
"PLAYLIST_EXPORT_ERROR": "错误:播放列表导出失败。",
"COPY_URL": "复制 URL 到剪贴板",
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "选择您想要的连接方式。稍后可以重命名。",
"METHOD_GROUP_LABEL": "连接方式",
"METHOD_URL_LABEL": "M3U URL",
"METHOD_URL_SUB": "粘贴远程 .m3u 或 .m3u8 的链接",
"METHOD_FILE_LABEL": "M3U 文件",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "播放列表 URL(m3u、m3u8)",
"URL_VALIDATION_ERROR": "请输入以 http:// 或 https:// 开头的有效 URL。",
"ADD_PLAYLIST": "添加播放列表",
"CORS_NOTE": "注意:为避免 CORS 问题,应用程序目前使用了一个公共 CORS 代理服务。如果您要上传包含敏感数据的播放列表,从文件导入会更加安全。",
"ERROR_FETCH_FAILED": "获取播放列表失败。请检查 URL 后重试。",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "仅在您信任此播放列表主机时继续。IPTVnator 将允许此主机使用无效 TLS 证书,但其他主机仍需要有效证书。"
},
"TEXT_IMPORT": {
"LABEL": "从文本导入播放列表(m3u, m3u8)",
"BUTTON_LABEL": "导入"
"LABEL": "从文本导入播放列表(m3u, m3u8)"
},
"XTREAM_PLAYLIST": {
"TITLE": "播放列表标题",
"SERVER_URL": "服务器 URL",
"USERNAME": "用户名",
"PASSWORD": "密码",
"ADD": "添加",
"TEST_CREDENTIALS": "测试凭据",
"URL_VALIDATION_ERROR": "应该是带有协议的有效 url(例如 http://example.com 或 https://example.com:4324)",
"URL_VALIDATION_ERROR": "请输入以 http:// 或 https:// 开头的有效 URL。",
"URL_HINT": "服务器地址,例如 http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "请输入用户名和密码以测试连接。",
"TESTING": "正在测试连接…",
@@ -199,7 +200,6 @@
}
},
"STALKER_PORTAL": {
"TITLE": "标题",
"MAC_ADDRESS": "MAC 地址",
"MAC_ADDRESS_HINT": "格式为 00:1A:79:XX:XX:XX。连字符、点号和小写字母会自动更正。",
"MAC_ADDRESS_OUI_HINT": "此地址不在 Infomir 的 00:1A:79 范围内。多数门户只接受该范围内的地址,对其他地址会直接拒绝且不作说明。",
@@ -218,8 +218,9 @@
"SERVER_URL": "服务器 URL",
"URL_HINT": "请输入主机或以 /c、portal.php、server/load.php 结尾的地址。",
"URL_VALIDATION_ERROR": "请输入以 http:// 或 https:// 开头的有效 URL。",
"ADD": "添加",
"VALIDATING": "正在验证门户…",
"VALIDATED_EXPIRES": "门户已验证。将于 {{date}} 到期。",
"ADDED_WITHOUT_VALIDATION": "门户没有响应。已添加,但未经验证。",
"CREDENTIALS_HINT": "仅在门户要求登录名和密码时才需要填写",
"AUTH_FAILED": "门户身份验证失败。请检查 URL 和 MAC 地址。",
"EDIT_UNREACHABLE": "门户没有响应。未保存任何更改。请检查 URL 后重试。",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "门户为此 MAC 地址注册的是另一个设备 ID。",
"PORTAL_MESSAGE": "门户返回信息:{{message}}"
},
"SHOW_PASSWORD": "显示密码",
"FILTER_BY_NAME": "按名称筛选",
"FILTER_AND_SORT": "筛选与排序",
"SORT_PLAYLISTS": "排序播放列表",
+10 -8
View File
@@ -135,6 +135,7 @@
},
"ADD_PLAYLIST": {
"PICK_METHOD": "請選擇您要使用的連線方式,之後仍可重新命名。",
"METHOD_GROUP_LABEL": "連線方式",
"METHOD_URL_LABEL": "M3U URL",
"METHOD_URL_SUB": "貼上遠端 .m3u 或 .m3u8 的連結",
"METHOD_FILE_LABEL": "M3U 檔案",
@@ -158,6 +159,7 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "播放清單 URL(m3u、m3u8)",
"URL_VALIDATION_ERROR": "請輸入以 http:// 或 https:// 開頭的有效網址。",
"ADD_PLAYLIST": "新增播放清單",
"CORS_NOTE": "為了避免 CORS 問題,此應用程式目前使用了一個公開的 CORS 代理服務。如果您要上傳包含敏感資料的播放清單,建議在相鄰的分頁中將其作為檔案匯入。",
"ERROR_FETCH_FAILED": "無法擷取播放清單。請檢查網址後重試。",
@@ -171,17 +173,16 @@
"TRUST_TLS_HOST_WARNING": "僅在您信任此播放清單主機時繼續。IPTVnator 將允許此主機使用無效 TLS 憑證,但其他主機仍需要有效憑證。"
},
"TEXT_IMPORT": {
"LABEL": "將 m3u(8)播放清單以純文字的方式插入",
"BUTTON_LABEL": "匯入"
"LABEL": "將 m3u(8)播放清單以純文字的方式插入"
},
"XTREAM_PLAYLIST": {
"TITLE": "播放清單標題",
"SERVER_URL": "伺服器網址",
"USERNAME": "使用者名稱",
"PASSWORD": "密碼",
"ADD": "新增",
"TEST_CREDENTIALS": "測試憑證",
"URL_VALIDATION_ERROR": "必須是有效的網址並包含協定(例如 http://example.com 或 https://example.com:4324)",
"URL_VALIDATION_ERROR": "請輸入以 http:// 或 https:// 開頭的有效網址。",
"URL_HINT": "伺服器位址,例如 http://host:port",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "請輸入使用者名稱和密碼以測試連線。",
"TESTING": "正在測試連線…",
@@ -199,8 +200,7 @@
}
},
"STALKER_PORTAL": {
"TITLE": "標題",
"MAC_ADDRESS": "Mac 位址",
"MAC_ADDRESS": "MAC 位址",
"MAC_ADDRESS_HINT": "格式為 00:1A:79:XX:XX:XX。連字號、點號與小寫字母會自動為您修正。",
"MAC_ADDRESS_OUI_HINT": "此位址不在 Infomir 的 00:1A:79 範圍內。多數入口網站只接受該範圍的位址,並會在不說明原因的情況下拒絕其他位址。",
"MAC_ADDRESS_ERROR": "請輸入 12 位十六進位數字的 MAC 位址,例如 00:1A:79:12:34:56。",
@@ -218,8 +218,9 @@
"SERVER_URL": "伺服器網址",
"URL_HINT": "請輸入主機或以 /c、portal.php、server/load.php 結尾的位址。",
"URL_VALIDATION_ERROR": "請輸入以 http:// 或 https:// 開頭的有效網址。",
"ADD": "新增",
"VALIDATING": "正在驗證入口網站...",
"VALIDATING": "正在驗證入口網站…",
"VALIDATED_EXPIRES": "入口網站已驗證。到期日:{{date}}。",
"ADDED_WITHOUT_VALIDATION": "入口網站沒有回應。已在未驗證的情況下新增。",
"CREDENTIALS_HINT": "僅在入口網站要求登入名稱與密碼時才需填寫",
"AUTH_FAILED": "入口網站驗證失敗。請檢查網址與 MAC 位址。",
"EDIT_UNREACHABLE": "入口網站沒有回應。未儲存任何變更。請檢查網址後再試一次。",
@@ -229,6 +230,7 @@
"DEVICE_CONFLICT": "入口網站為此 MAC 位址註冊的是另一組裝置 ID。",
"PORTAL_MESSAGE": "入口網站回報:{{message}}"
},
"SHOW_PASSWORD": "顯示密碼",
"FILTER_BY_NAME": "依名稱篩選",
"FILTER_AND_SORT": "篩選與排序",
"SORT_PLAYLISTS": "排序播放清單",
@@ -714,6 +714,28 @@ Settings use the same system but are flatter than content-heavy views.
alike. `theme-tokens.e2e.ts` checks the label and the error fill in both
themes.
## Forms
The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one
vocabulary, so a field reads the same wherever it appears:
- **Name.** The source name is labelled "Playlist title"
(`HOME.XTREAM_PLAYLIST.TITLE`) in every add form. Every add form submits
with "Add playlist" (`HOME.URL_UPLOAD.ADD_PLAYLIST`).
- **Passwords.** A password input is masked and has a `mat-icon-button`
suffix with `PasswordVisibilityToggleDirective`
(`@iptvnator/ui/components/password-visibility-toggle`). The input binds
`[type]="toggle.inputType()"`; the button keeps one translated label
("Show password", `HOME.SHOW_PASSWORD`) and exposes its state through
`aria-pressed`, as an ARIA toggle button does.
- **URLs.** A URL field has a neutral `mat-hint` where the format is not
obvious, and its own `mat-error`. Never borrow another field's message.
- **Feedback.** While the dialog stays open, a check or refusal is shown
inline under the URL field in a `role="status"` paragraph. The message is
translated in the template and cleared by any edit. Use a snackbar only for
outcomes that close the dialog. `add-source-forms.e2e.ts` in `web-e2e`
covers the shared labels, the toggle and the URL errors.
## Source Type Icons
`SOURCE_TYPE_ICONS` in `@iptvnator/shared/interfaces` is the only source of
+7 -2
View File
@@ -743,8 +743,13 @@ otherwise throws `StalkerPortalError('auth-failed')` carrying the body.
### Error surfacing
`StalkerPortalError.portalText` holds the portal's own words. The import
dialog shows them in its failure snackbar (with kind-specific i18n headlines,
`HOME.STALKER_PORTAL.*`); the workspace context panel replaces the generic
dialog shows them inline under the portal URL, in the same `role="status"`
paragraph the Xtream form uses for its connection test, after a kind-specific
i18n headline (`HOME.STALKER_PORTAL.*`, mapped by `toStalkerImportFeedback`).
The template translates both parts, and any edit clears the message. Outcomes
that close the dialog (validated with an expiry date, or added without
validation) use translated snackbars instead. The workspace context panel
replaces the generic
"could not load categories" hint with the portal text (or the login-required
guidance) when category loading failed with a portal refusal
(`stalkerCategoryErrorDescription` in `workspace-context-panel.component.ts`).
@@ -12,7 +12,9 @@
<div
class="apd-method-grid"
role="radiogroup"
aria-label="Source method"
[attr.aria-label]="
'HOME.ADD_PLAYLIST.METHOD_GROUP_LABEL' | translate
"
>
@for (opt of methodOptions; track opt.value) {
<button
@@ -128,7 +130,7 @@
)
"
>
{{ 'HOME.TEXT_IMPORT.BUTTON_LABEL' | translate }}
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
@case ('xtream') {
@@ -159,7 +161,7 @@
"
(click)="xtreamImport()?.addPlaylist()"
>
{{ 'HOME.XTREAM_PLAYLIST.ADD' | translate }}
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
@case ('stalker') {
@@ -170,9 +172,7 @@
type="button"
disabled
>
{{
'HOME.STALKER_PORTAL.VALIDATING' | translate
}}...
{{ 'HOME.STALKER_PORTAL.VALIDATING' | translate }}
</button>
} @else {
<button
@@ -182,7 +182,7 @@
[disabled]="!stalkerImport()?.form?.valid"
(click)="stalkerImport()?.addPlaylist()"
>
{{ 'HOME.STALKER_PORTAL.ADD' | translate }}
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
}
@@ -2,9 +2,10 @@ 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 { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { DataService } from '@iptvnator/services';
import { StalkerPortalDiscoveryService } from '@iptvnator/portal/stalker/data-access';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import {
PLAYLIST_PARSE_BY_URL,
ProviderImportCandidate,
@@ -418,3 +419,68 @@ describe('AddPlaylistDialogComponent', () => {
component.method.set(type);
}
});
describe('AddPlaylistDialogComponent actions', () => {
function render(type: PlaylistType) {
TestBed.configureTestingModule({
imports: [AddPlaylistDialogComponent, TranslateModule.forRoot()],
providers: [
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
{ provide: Store, useValue: { dispatch: jest.fn() } },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{ provide: MAT_DIALOG_DATA, useValue: { type } },
{
provide: RuntimeCapabilitiesService,
useValue: { isElectron: true },
},
{
provide: StalkerPortalDiscoveryService,
useValue: { discover: jest.fn() },
},
],
});
const fixture = TestBed.createComponent(AddPlaylistDialogComponent);
fixture.detectChanges();
return fixture;
}
function primaryLabel(root: HTMLElement): string | undefined {
const buttons = root.querySelectorAll<HTMLButtonElement>(
'.apd-actions__decision button'
);
return buttons[buttons.length - 1]?.textContent?.trim();
}
it.each<PlaylistType>(['url', 'text', 'xtream', 'stalker'])(
'submits the %s form with the shared "Add playlist" label',
(type) => {
const fixture = render(type);
expect(primaryLabel(fixture.nativeElement)).toBe(
'HOME.URL_UPLOAD.ADD_PLAYLIST'
);
}
);
it('shows a single ellipsis while a Stalker portal validates', () => {
const fixture = render('stalker');
fixture.componentInstance.stalkerImport()?.isLoading.set(true);
fixture.detectChanges();
expect(primaryLabel(fixture.nativeElement)).toBe(
'HOME.STALKER_PORTAL.VALIDATING'
);
});
it('names the method picker with a translated label', () => {
const fixture = render('url');
const group = (fixture.nativeElement as HTMLElement).querySelector(
'[role="radiogroup"]'
);
expect(group?.getAttribute('aria-label')).toBe(
'HOME.ADD_PLAYLIST.METHOD_GROUP_LABEL'
);
});
});
@@ -1,4 +1,7 @@
import type { StalkerPortalErrorKind } from '@iptvnator/portal/stalker/data-access';
import {
asStalkerPortalError,
type StalkerPortalErrorKind,
} from '@iptvnator/portal/stalker/data-access';
import type { StalkerPortalIdentity } from '@iptvnator/shared/interfaces';
/** The `stalker*` playlist columns the import form writes. */
@@ -49,3 +52,32 @@ export const STALKER_IMPORT_ERROR_KEY_BY_KIND: Readonly<
blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
};
/**
* What the import form shows inline when a portal refuses it: a translation
* key, plus the portal's own explanation when it gave one. The template
* translates both, so the message follows a language switch.
*/
export interface StalkerImportFeedback {
key: string;
portalText?: string;
}
/**
* Turns an authentication failure into feedback the user can act on. The
* portal explains refusals itself (`msg`/`block_msg`, or one of the
* documented plain-text bodies); its own words are relayed verbatim.
*/
export function toStalkerImportFeedback(error: unknown): StalkerImportFeedback {
const portalError = asStalkerPortalError(error);
if (!portalError) {
return { key: 'HOME.STALKER_PORTAL.AUTH_FAILED' };
}
return {
key: STALKER_IMPORT_ERROR_KEY_BY_KIND[portalError.kind],
...(portalError.portalText
? { portalText: portalError.portalText }
: {}),
};
}
@@ -1,7 +1,7 @@
<form [formGroup]="form">
<mat-form-field class="w-full">
<mat-label for="title">{{
'HOME.STALKER_PORTAL.TITLE' | translate
'HOME.XTREAM_PLAYLIST.TITLE' | translate
}}</mat-label>
<input matInput type="text" id="title" formControlName="title" />
</mat-form-field>
@@ -20,6 +20,17 @@
'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | translate
}}</mat-error>
</mat-form-field>
@if (feedback(); as message) {
<p #feedbackMessage role="status" aria-live="polite">
{{ message.key | translate }}
@if (message.portalText) {
{{
'HOME.STALKER_PORTAL.PORTAL_MESSAGE'
| translate: { message: message.portalText }
}}
}
</p>
}
<mat-form-field class="w-full" subscriptSizing="dynamic">
<mat-label for="macAddress">{{
'HOME.STALKER_PORTAL.MAC_ADDRESS' | translate
@@ -142,10 +153,19 @@
}}</mat-label>
<input
matInput
type="password"
[type]="passwordToggle.inputType()"
id="password"
formControlName="password"
/>
<button
mat-icon-button
matSuffix
appPasswordVisibilityToggle
#passwordToggle="appPasswordVisibilityToggle"
[attr.aria-label]="'HOME.SHOW_PASSWORD' | translate"
>
<mat-icon>{{ passwordToggle.icon() }}</mat-icon>
</button>
<mat-hint>{{
'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate
}}</mat-hint>
@@ -2,7 +2,7 @@ import { webcrypto } from 'node:crypto';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
stalkerSessionFingerprint,
StalkerPortalDiscoveryService,
@@ -305,11 +305,12 @@ describe('StalkerPortalImportComponent identity handling', () => {
await component.addPlaylist();
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
undefined,
expect.any(Object)
);
// Inline, like the Xtream connection test: the dialog stays open, so
// a snackbar would cover the footer the user acts from next.
expect(component.feedback()).toEqual({
key: 'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
});
expect(snackBar.open).not.toHaveBeenCalled();
expect(store.dispatch).not.toHaveBeenCalled();
});
@@ -374,13 +375,103 @@ describe('StalkerPortalImportComponent identity handling', () => {
await component.addPlaylist();
// The translate mock returns keys, so both the kind headline and the
// portal-message wrapper must be present.
expect(component.feedback()).toEqual({
key: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
portalText: 'device conflict - device_id mismatch',
});
expect(snackBar.open).not.toHaveBeenCalled();
});
it('shows the generic refusal inline when a canonical portal is unreachable', async () => {
portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' });
component.form.patchValue({
title: 'Offline Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl:
'https://portal.example.com/stalker_portal/server/load.php',
});
await component.addPlaylist();
expect(component.feedback()).toEqual({
key: 'HOME.STALKER_PORTAL.AUTH_FAILED',
});
expect(snackBar.open).not.toHaveBeenCalled();
expect(store.dispatch).not.toHaveBeenCalled();
});
it('announces an unvalidated import with a translated snackbar', async () => {
portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' });
component.form.patchValue({
title: 'Offline Panel',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://panel.example.com/c',
});
await component.addPlaylist();
// The dialog closes on this path, so an inline message would vanish.
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.PORTAL_REFUSED HOME.STALKER_PORTAL.PORTAL_MESSAGE',
'HOME.STALKER_PORTAL.ADDED_WITHOUT_VALIDATION',
undefined,
expect.any(Object)
);
expect(component.feedback()).toBeNull();
expect(store.dispatch).toHaveBeenCalledTimes(1);
});
it('translates the expiry announced after a validated import', async () => {
const translate = TestBed.inject(TranslateService);
portalDiscovery.discover.mockResolvedValue({
status: 'resolved',
portalUrl:
'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
token: 'token-1',
accountInfo: { login: 'demo', expire_date: 1893456000 },
});
component.form.patchValue({
title: 'Expiring Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://portal.example.com/stalker_portal/c',
});
await component.addPlaylist();
expect(translate.instant).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.VALIDATED_EXPIRES',
{ date: new Date(1893456000 * 1000).toLocaleDateString() }
);
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.VALIDATED_EXPIRES',
undefined,
expect.any(Object)
);
});
it('drops a refusal once the user edits the form or retries', async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'auth-rejected',
portalUrl:
'https://portal.example.com/stalker_portal/server/load.php',
error: new StalkerPortalError('login-required'),
});
component.form.patchValue({
title: 'Login Portal',
macAddress: '00:1A:79:00:00:08',
portalUrl: 'https://portal.example.com/stalker_portal/c',
});
await component.addPlaylist();
expect(component.feedback()).not.toBeNull();
component.form.controls.username.setValue('demo');
expect(component.feedback()).toBeNull();
await component.addPlaylist();
expect(component.feedback()).not.toBeNull();
component.clearForm();
expect(component.feedback()).toBeNull();
});
it('classifies the offline fallback on the normalized URL, not the raw query', async () => {
@@ -934,11 +1025,10 @@ describe('StalkerPortalImportComponent identity handling', () => {
await component.addPlaylist();
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.DEVICE_CONFLICT HOME.STALKER_PORTAL.PORTAL_MESSAGE',
undefined,
expect.any(Object)
);
expect(component.feedback()).toEqual({
key: 'HOME.STALKER_PORTAL.DEVICE_CONFLICT',
portalText: 'device conflict - device_id mismatch',
});
});
it('normalizes a query-carrying /c URL in the unreachable-host fallback', async () => {
@@ -986,3 +1076,119 @@ describe('StalkerPortalImportComponent identity handling', () => {
);
});
});
describe('StalkerPortalImportComponent form', () => {
let portalDiscovery: { discover: jest.Mock };
afterEach(() => {
// jsdom has no scrollIntoView; one test installs a spy for it.
delete (HTMLElement.prototype as { scrollIntoView?: unknown })
.scrollIntoView;
});
function render() {
portalDiscovery = { discover: jest.fn() };
TestBed.configureTestingModule({
imports: [StalkerPortalImportComponent, TranslateModule.forRoot()],
providers: [
{
provide: StalkerPortalDiscoveryService,
useValue: portalDiscovery,
},
{ provide: Store, useValue: { dispatch: jest.fn() } },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
],
});
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
HOME: {
STALKER_PORTAL: {
PORTAL_REFUSED: 'Refused.',
PORTAL_MESSAGE: 'The portal reported: {{message}}',
},
},
});
translate.use('en');
const fixture = TestBed.createComponent(StalkerPortalImportComponent);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
return { fixture, root, component: fixture.componentInstance };
}
it('labels the name field like the other add-source forms', () => {
const { root } = render();
expect(
root.querySelector('#title')?.closest('mat-form-field')?.textContent
).toContain('HOME.XTREAM_PLAYLIST.TITLE');
});
it('masks the password until the visibility toggle is pressed', () => {
const { fixture, root } = render();
const password = root.querySelector('#password') as HTMLInputElement;
const toggle = password
.closest('mat-form-field')
?.querySelector('button') as HTMLButtonElement;
expect(password.type).toBe('password');
expect(toggle.getAttribute('aria-label')).toBe('HOME.SHOW_PASSWORD');
expect(toggle.getAttribute('aria-pressed')).toBe('false');
toggle.click();
fixture.detectChanges();
expect(password.type).toBe('text');
expect(toggle.getAttribute('aria-pressed')).toBe('true');
});
it('masks the password again when the form is cleared', () => {
const { fixture, root, component } = render();
const password = root.querySelector('#password') as HTMLInputElement;
const toggle = password
.closest('mat-form-field')
?.querySelector('button') as HTMLButtonElement;
toggle.click();
fixture.detectChanges();
expect(password.type).toBe('text');
component.clearForm();
fixture.detectChanges();
expect(password.type).toBe('password');
expect(toggle.getAttribute('aria-pressed')).toBe('false');
});
it('shows a refusal inline under the portal URL and scrolls it into view', async () => {
const scrollIntoView = jest.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
const { fixture, root, component } = render();
portalDiscovery.discover.mockResolvedValue({
status: 'auth-rejected',
portalUrl:
'https://portal.example.com/stalker_portal/server/load.php',
error: new StalkerPortalError('blocked', 'account disabled'),
});
component.form.patchValue({
title: 'Blocked Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://portal.example.com/stalker_portal/c',
});
await component.addPlaylist();
fixture.detectChanges();
await fixture.whenStable();
const status = root.querySelector('[role="status"]') as HTMLElement;
expect(status.textContent?.replace(/\s+/g, ' ').trim()).toBe(
'Refused. The portal reported: account disabled'
);
expect(status.getAttribute('aria-live')).toBe('polite');
expect(
status.previousElementSibling?.querySelector('#portalUrl')
).not.toBeNull();
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
});
});
@@ -1,10 +1,14 @@
import {
afterRenderEffect,
Component,
ElementRef,
inject,
output,
signal,
viewChild,
ChangeDetectionStrategy,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormControl,
FormGroup,
@@ -12,15 +16,16 @@ import {
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
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 '@iptvnator/m3u-state';
import {
asStalkerPortalError,
legacyTransformStalkerPortalUrl,
normalizeStalkerPortalInputUrl,
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
@@ -38,8 +43,10 @@ import {
type StalkerDerivedDeviceIds,
validateStalkerMacAddressControl,
} from '@iptvnator/shared/interfaces';
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
import {
STALKER_IMPORT_ERROR_KEY_BY_KIND,
type StalkerImportFeedback,
toStalkerImportFeedback,
toStalkerPlaylistIdentityFields,
} from './stalker-import-identity';
@@ -59,7 +66,10 @@ interface StalkerSettledIdentity {
FormsModule,
MatCheckboxModule,
MatFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
PasswordVisibilityToggleDirective,
ReactiveFormsModule,
TranslatePipe,
],
@@ -130,12 +140,43 @@ export class StalkerPortalImportComponent {
readonly isLoading = signal(false);
/**
* Why the last import was refused, shown under the portal URL the same
* way the Xtream form shows its connection test. Outcomes that close the
* dialog (added, added without validation) use a snackbar instead,
* because an inline message would vanish with the form.
*/
readonly feedback = signal<StalkerImportFeedback | null>(null);
private readonly feedbackMessage =
viewChild<ElementRef<HTMLElement>>('feedbackMessage');
private readonly passwordToggle = viewChild(
PasswordVisibilityToggleDirective
);
/** Whether the device IDs are being generated from the MAC. */
readonly derivesDeviceIds = signal(false);
/** Stamps each derivation so a late one cannot overwrite a newer one. */
private deriveGeneration = 0;
constructor() {
// Like the Xtream connection test: a message describes the values it
// was produced for, so any edit makes it stale.
this.form.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(() => this.feedback.set(null));
// The form is long and Add sits in the dialog footer, so the user is
// usually scrolled to the credentials when a refusal comes back.
afterRenderEffect(() => {
if (this.feedback()) {
this.feedbackMessage()?.nativeElement.scrollIntoView?.({
block: 'nearest',
});
}
});
}
/**
* A MAC outside Infomir's range is imported anyway — plenty of resellers
* disable the check — but the stock portal answers a bare `{status: 1}`
@@ -308,6 +349,9 @@ export class StalkerPortalImportComponent {
// resolve into the freshly cleared form.
this.invalidatePendingDerivation();
this.derivesDeviceIds.set(false);
this.feedback.set(null);
// A cleared form is a fresh entry: the next password starts masked.
this.passwordToggle()?.hide();
this.form.controls.deviceId1.enable();
this.form.controls.deviceId2.enable();
this.form.reset({
@@ -333,6 +377,7 @@ export class StalkerPortalImportComponent {
}
this.isLoading.set(true);
this.feedback.set(null);
// The identity is frozen for the duration: an edit made now cannot
// reach the portal (discovery has the snapshot) and cannot be undone
// on it either (`get_profile` pins what it was sent), so the fields
@@ -422,7 +467,10 @@ export class StalkerPortalImportComponent {
stalkerAccountInfo.expireDate * 1000
);
this.snackBar.open(
`Portal validated. Expires: ${expireDate.toLocaleDateString()}`,
this.translate.instant(
'HOME.STALKER_PORTAL.VALIDATED_EXPIRES',
{ date: expireDate.toLocaleDateString() }
),
undefined,
{ duration: 3000 }
);
@@ -435,11 +483,7 @@ export class StalkerPortalImportComponent {
// The portal explains its own refusals — a demanded login, a
// rejected one, a device conflict — so relay those words
// instead of the generic "check URL and MAC".
this.snackBar.open(
this.buildAuthErrorMessage(discovery.error),
undefined,
{ duration: 8000 }
);
this.feedback.set(toStalkerImportFeedback(discovery.error));
if (discovery.abandonedInFlight) {
// The bounded error may arrive while get_profile is still
// on the wire. Keep Add and every identity field locked
@@ -457,11 +501,7 @@ export class StalkerPortalImportComponent {
// Unreachable host on a canonical-portal URL shape: the old
// flow aborted here too (its mandatory handshake could not
// succeed either).
this.snackBar.open(
'Failed to authenticate with portal. Please check URL and MAC address.',
undefined,
{ duration: 5000 }
);
this.feedback.set({ key: 'HOME.STALKER_PORTAL.AUTH_FAILED' });
return;
} else {
// Unreachable host on a panel-style URL: import with the
@@ -475,7 +515,9 @@ export class StalkerPortalImportComponent {
);
isFullStalkerPortal = false;
this.snackBar.open(
'Portal did not respond; added without validation.',
this.translate.instant(
'HOME.STALKER_PORTAL.ADDED_WITHOUT_VALIDATION'
),
undefined,
{ duration: 5000 }
);
@@ -537,28 +579,4 @@ export class StalkerPortalImportComponent {
this.isLoading.set(false);
}
}
/**
* Turns an authentication failure into a message the user can act on.
* The portal explains refusals itself (`msg`/`block_msg`, or one of the
* documented plain-text bodies); its own words are appended verbatim.
*/
private buildAuthErrorMessage(error: unknown): string {
const portalError = asStalkerPortalError(error);
const base = this.translate.instant(
portalError
? STALKER_IMPORT_ERROR_KEY_BY_KIND[portalError.kind]
: 'HOME.STALKER_PORTAL.AUTH_FAILED'
);
if (portalError?.portalText) {
const detail = this.translate.instant(
'HOME.STALKER_PORTAL.PORTAL_MESSAGE',
{ message: portalError.portalText }
);
return `${base} ${detail}`;
}
return base;
}
}
@@ -2,6 +2,9 @@
<mat-form-field class="w-full">
<mat-label>{{ 'HOME.URL_UPLOAD.PLAYLIST_URL' | translate }}</mat-label>
<input type="url" matInput formControlName="playlistUrl" />
<mat-error>{{
'HOME.URL_UPLOAD.URL_VALIDATION_ERROR' | translate
}}</mat-error>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{ 'HOME.XTREAM_PLAYLIST.TITLE' | translate }}</mat-label>
@@ -109,3 +109,62 @@ describe('UrlUploadComponent', () => {
expect(component.form.pristine).toBeTruthy();
});
});
describe('UrlUploadComponent form', () => {
let component: UrlUploadComponent;
let fixture: ComponentFixture<UrlUploadComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
UrlUploadComponent,
TranslateModule.forRoot(),
NoopAnimationsModule,
],
providers: [
{
provide: RuntimeCapabilitiesService,
useValue: { isElectron: true },
},
],
});
fixture = TestBed.createComponent(UrlUploadComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
function fieldText(controlName: string): string {
return (
(fixture.nativeElement as HTMLElement)
.querySelector(`[formcontrolname="${controlName}"]`)
?.closest('mat-form-field')
?.textContent?.replace(/\s+/g, ' ')
.trim() ?? ''
);
}
it('labels the name field like the other add-source forms', () => {
expect(fieldText('playlistName')).toContain(
'HOME.XTREAM_PLAYLIST.TITLE'
);
});
it('explains an invalid playlist URL once the field was touched', () => {
const root = fixture.nativeElement as HTMLElement;
const url = component.form.controls.playlistUrl;
url.setValue('playlist.m3u');
fixture.detectChanges();
expect(root.querySelector('mat-error')).toBeNull();
url.markAsTouched();
fixture.detectChanges();
expect(root.querySelector('mat-error')?.textContent?.trim()).toBe(
'HOME.URL_UPLOAD.URL_VALIDATION_ERROR'
);
url.setValue('https://example.org/playlist.m3u');
fixture.detectChanges();
expect(root.querySelector('mat-error')).toBeNull();
});
});
@@ -16,10 +16,10 @@
formControlName="serverUrl"
(input)="extractParams(form.value.serverUrl ?? '')"
/>
<mat-hint>{{
<mat-hint>{{ 'HOME.XTREAM_PLAYLIST.URL_HINT' | translate }}</mat-hint>
<mat-error>{{
'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR' | translate
}}</mat-hint>
<mat-error>{{ 'SETTINGS.EPG_URL_ERROR' | translate }}</mat-error>
}}</mat-error>
</mat-form-field>
@if (connectionTest.messageKey()) {
<p role="status" aria-live="polite">
@@ -39,6 +39,20 @@
<mat-label for="password">{{
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
}}</mat-label>
<input matInput type="text" id="password" formControlName="password" />
<input
matInput
[type]="passwordToggle.inputType()"
id="password"
formControlName="password"
/>
<button
mat-icon-button
matSuffix
appPasswordVisibilityToggle
#passwordToggle="appPasswordVisibilityToggle"
[attr.aria-label]="'HOME.SHOW_PASSWORD' | translate"
>
<mat-icon>{{ passwordToggle.icon() }}</mat-icon>
</button>
</mat-form-field>
</form>
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { XtreamConnectionTestService } from '@iptvnator/services';
import { XtreamCodeImportComponent } from './xtream-code-import.component';
@@ -198,3 +199,117 @@ describe('XtreamCodeImportComponent', () => {
expect(component.connectionTest.result()).toBeNull();
});
});
describe('XtreamCodeImportComponent form', () => {
function render() {
TestBed.configureTestingModule({
imports: [XtreamCodeImportComponent, TranslateModule.forRoot()],
providers: [
{ provide: Store, useValue: { dispatch: jest.fn() } },
{
provide: XtreamConnectionTestService,
useValue: { test: jest.fn() },
},
],
});
const fixture = TestBed.createComponent(XtreamCodeImportComponent);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
return { fixture, root, component: fixture.componentInstance };
}
function fieldText(root: HTMLElement, inputId: string): string {
return (
root
.querySelector(`#${inputId}`)
?.closest('mat-form-field')
?.textContent?.replace(/\s+/g, ' ')
.trim() ?? ''
);
}
it('labels the name field like the other add-source forms', () => {
const { root } = render();
expect(fieldText(root, 'title')).toContain(
'HOME.XTREAM_PLAYLIST.TITLE'
);
});
it('masks the password until the visibility toggle is pressed', () => {
const { fixture, root } = render();
const password = root.querySelector('#password') as HTMLInputElement;
const toggle = password
.closest('mat-form-field')
?.querySelector('button') as HTMLButtonElement;
expect(password.type).toBe('password');
expect(toggle.type).toBe('button');
expect(toggle.getAttribute('aria-label')).toBe('HOME.SHOW_PASSWORD');
expect(toggle.getAttribute('aria-pressed')).toBe('false');
toggle.click();
fixture.detectChanges();
expect(password.type).toBe('text');
expect(toggle.getAttribute('aria-pressed')).toBe('true');
expect(toggle.getAttribute('aria-label')).toBe('HOME.SHOW_PASSWORD');
toggle.click();
fixture.detectChanges();
expect(password.type).toBe('password');
expect(toggle.getAttribute('aria-pressed')).toBe('false');
});
it('masks the password again when the form is cleared', () => {
const { fixture, root, component } = render();
const password = root.querySelector('#password') as HTMLInputElement;
const toggle = password
.closest('mat-form-field')
?.querySelector('button') as HTMLButtonElement;
toggle.click();
fixture.detectChanges();
expect(password.type).toBe('text');
component.clearForm();
fixture.detectChanges();
expect(password.type).toBe('password');
expect(toggle.getAttribute('aria-pressed')).toBe('false');
});
it('shows a neutral server hint until the URL is invalid', () => {
const { fixture, root, component } = render();
expect(fieldText(root, 'serverUrl')).toContain(
'HOME.XTREAM_PLAYLIST.URL_HINT'
);
expect(root.querySelector('mat-error')).toBeNull();
component.form.controls.serverUrl.setValue('ftp://panel.example');
component.form.controls.serverUrl.markAsTouched();
fixture.detectChanges();
const error = root.querySelector('mat-error');
expect(error?.textContent?.trim()).toBe(
'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR'
);
expect(fieldText(root, 'serverUrl')).not.toContain(
'SETTINGS.EPG_URL_ERROR'
);
});
it('explains a URL that carries inline credentials as invalid', () => {
const { fixture, root, component } = render();
component.form.controls.serverUrl.setValue(
'https://user:pass@panel.example'
);
component.form.controls.serverUrl.markAsTouched();
fixture.detectChanges();
expect(root.querySelector('mat-error')?.textContent?.trim()).toBe(
'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR'
);
});
});
@@ -3,6 +3,7 @@ import {
EventEmitter,
Output,
inject,
viewChild,
ChangeDetectionStrategy,
} from '@angular/core';
import {
@@ -14,12 +15,15 @@ import {
ValidationErrors,
Validators,
} from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
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 '@iptvnator/m3u-state';
import { createXtreamConnectionTestState } from '@iptvnator/services';
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
import {
createRandomId,
extractXtreamCredentialsFromUrl,
@@ -47,7 +51,10 @@ function xtreamServerUrlValidator(
imports: [
FormsModule,
MatFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
PasswordVisibilityToggleDirective,
ReactiveFormsModule,
TranslatePipe,
],
@@ -87,6 +94,9 @@ export class XtreamCodeImportComponent {
readonly store = inject(Store);
readonly connectionTest = createXtreamConnectionTestState(this.form);
private readonly passwordToggle = viewChild(
PasswordVisibilityToggleDirective
);
get isTestingConnection(): boolean {
return this.connectionTest.testing();
@@ -97,6 +107,8 @@ export class XtreamCodeImportComponent {
}
clearForm(): void {
// A cleared form is a fresh entry: the next password starts masked.
this.passwordToggle()?.hide();
this.form.reset({
_id: createRandomId(),
title: '',
@@ -0,0 +1,110 @@
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 { TranslateModule } from '@ngx-translate/core';
import { of, Subject } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { PlaylistInfoComponent } from './playlist-info.component';
import { STALKER_PLAYLIST_CONNECTION_EDITOR } from './stalker-playlist-connection-editor.token';
/** The edit dialog masks a stored password exactly like the add forms. */
describe('PlaylistInfoComponent password field', () => {
const base = {
id: 'playlist-1',
_id: 'playlist-1',
title: 'Source',
count: 0,
importDate: '2026-04-01T00:00:00.000Z',
autoRefresh: false,
username: 'user1',
password: 'pass1',
};
const xtream = {
...base,
serverUrl: 'http://xtream.example:8080',
} as Playlist & { id: string };
const stalker = {
...base,
portalUrl: 'http://portal.example/portal.php',
macAddress: '00:1A:79:00:00:01',
} as Playlist & { id: string };
function render(playlist: Playlist & { id: string }) {
TestBed.configureTestingModule({
imports: [PlaylistInfoComponent, TranslateModule.forRoot()],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: playlist },
{
provide: PlaylistsService,
useValue: { getPlaylistById: jest.fn(() => of(playlist)) },
},
{ provide: DatabaseService, useValue: {} },
{
provide: EpgRuntimeBridgeService,
useValue: { supportsDataManagement: false },
},
{ provide: Store, useValue: { dispatch: jest.fn() } },
{
provide: SettingsStore,
useValue: { getSettings: jest.fn(() => ({ epgUrl: [] })) },
},
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: MatDialogRef,
useValue: { beforeClosed: () => new Subject<void>() },
},
{
provide: RuntimeCapabilitiesService,
useValue: { supportsDesktopFileSave: false },
},
{ provide: STALKER_PLAYLIST_CONNECTION_EDITOR, useValue: {} },
],
});
const fixture = TestBed.createComponent(PlaylistInfoComponent);
fixture.detectChanges();
return fixture;
}
it.each([
['Xtream', xtream],
['Stalker', stalker],
])(
'masks a stored %s password until it is revealed',
async (_, playlist) => {
const fixture = render(playlist);
await fixture.whenStable();
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
const password = root.querySelector(
'input[formcontrolname="password"]'
) as HTMLInputElement;
const toggle = password
.closest('mat-form-field')
?.querySelector('button') as HTMLButtonElement;
expect(password.value).toBe('pass1');
expect(password.type).toBe('password');
expect(toggle.type).toBe('button');
expect(toggle.getAttribute('aria-label')).toBe(
'HOME.SHOW_PASSWORD'
);
expect(toggle.getAttribute('aria-pressed')).toBe('false');
toggle.click();
fixture.detectChanges();
expect(password.type).toBe('text');
expect(toggle.getAttribute('aria-pressed')).toBe('true');
// A toggle never submits the edit form it sits in.
expect(fixture.componentInstance.isSaving()).toBe(false);
}
);
});
@@ -107,7 +107,20 @@
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
}}</mat-label>
<input matInput formControlName="password" />
<input
matInput
[type]="passwordToggle.inputType()"
formControlName="password"
/>
<button
mat-icon-button
matSuffix
appPasswordVisibilityToggle
#passwordToggle="appPasswordVisibilityToggle"
[attr.aria-label]="'HOME.SHOW_PASSWORD' | translate"
>
<mat-icon>{{ passwordToggle.icon() }}</mat-icon>
</button>
</mat-form-field>
}
@if (playlist.serverUrl && !playlist.portalUrl) {
@@ -52,6 +52,7 @@ import {
normalizeEpgUrls,
resolvePlaylistEpgSourceState,
} from '@iptvnator/shared/m3u-utils';
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
import {
hasStalkerConnectionChanged,
omitStalkerConnection,
@@ -175,6 +176,7 @@ type DesktopFileSaveBridge = Pick<
MatIconButton,
MatInputModule,
MatTooltip,
PasswordVisibilityToggleDirective,
ReactiveFormsModule,
TranslatePipe,
],
+1
View File
@@ -16,6 +16,7 @@ export * from './lib/portal-detail-shell/portal-detail-shell.component';
export * from './lib/view-in-portal-action/view-in-portal-action.component';
export * from './lib/view-in-portal-action/view-in-portal-handoff.token';
export * from './lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component';
export * from './lib/password-visibility-toggle/password-visibility-toggle.directive';
export * from './lib/progress-capsule/progress-capsule.component';
export * from './lib/resizable/resizable.directive';
export * from './lib/season-container/season-container.component';
@@ -0,0 +1,90 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { PasswordVisibilityToggleDirective } from './password-visibility-toggle.directive';
@Component({
imports: [PasswordVisibilityToggleDirective],
template: `
<input [type]="toggle.inputType()" />
<button
appPasswordVisibilityToggle
#toggle="appPasswordVisibilityToggle"
aria-label="Show password"
>
{{ toggle.icon() }}
</button>
`,
})
class HostComponent {}
describe('PasswordVisibilityToggleDirective', () => {
function render() {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
return {
fixture,
input: root.querySelector('input') as HTMLInputElement,
button: root.querySelector('button') as HTMLButtonElement,
};
}
it('starts masked and is a non-submitting toggle button', () => {
const { input, button } = render();
expect(input.type).toBe('password');
expect(button.type).toBe('button');
expect(button.getAttribute('aria-pressed')).toBe('false');
expect(button.textContent?.trim()).toBe('visibility');
});
it('reveals and masks again on each click, keeping its label', () => {
const { fixture, input, button } = render();
button.click();
fixture.detectChanges();
expect(input.type).toBe('text');
expect(button.getAttribute('aria-pressed')).toBe('true');
expect(button.textContent?.trim()).toBe('visibility_off');
expect(button.getAttribute('aria-label')).toBe('Show password');
button.click();
fixture.detectChanges();
expect(input.type).toBe('password');
expect(button.getAttribute('aria-pressed')).toBe('false');
});
it('masks again on hide()', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
const button = root.querySelector('button') as HTMLButtonElement;
button.click();
fixture.detectChanges();
fixture.debugElement
.query((node) => node.name === 'button')
.injector.get(PasswordVisibilityToggleDirective)
.hide();
fixture.detectChanges();
expect(root.querySelector('input')?.type).toBe('password');
expect(button.getAttribute('aria-pressed')).toBe('false');
});
it('does not submit the surrounding form', () => {
const form = document.createElement('form');
const submit = jest.fn((event: Event) => event.preventDefault());
form.addEventListener('submit', submit);
const { button } = render();
form.appendChild(button);
document.body.appendChild(form);
button.click();
expect(submit).not.toHaveBeenCalled();
form.remove();
});
});
@@ -0,0 +1,37 @@
import { computed, Directive, signal } from '@angular/core';
/**
* Turns a `mat-icon-button` suffix into the show/hide control of a password
* field. The field starts masked; the input binds `[type]="toggle.inputType()"`
* and the icon renders `toggle.icon()`.
*
* It is a toggle button in the ARIA sense: the template gives it one constant
* label ("Show password") and `aria-pressed` carries the state, so a screen
* reader announces "Show password, toggle button, pressed" instead of a label
* that flips under the user's focus.
*/
@Directive({
selector: 'button[appPasswordVisibilityToggle]',
exportAs: 'appPasswordVisibilityToggle',
host: {
type: 'button',
'[attr.aria-pressed]': 'visible()',
'(click)': 'toggle()',
},
})
export class PasswordVisibilityToggleDirective {
readonly visible = signal(false);
readonly inputType = computed(() => (this.visible() ? 'text' : 'password'));
readonly icon = computed(() =>
this.visible() ? 'visibility_off' : 'visibility'
);
toggle(): void {
this.visible.update((visible) => !visible);
}
/** Masks the field again, e.g. when its form is cleared for a new entry. */
hide(): void {
this.visible.set(false);
}
}
+4 -1
View File
@@ -462,7 +462,10 @@ async function addStalkerPortal(page: Page): Promise<void> {
await dialog.locator('#portalUrl').fill(STALKER_FIXTURE_PORTAL_URL);
await dialog.locator('#macAddress').fill(STALKER_FIXTURE_MAC);
// Endpoint discovery probes the mock before the row is written.
await dialog.getByRole('button', { name: /^add$/i }).last().click();
await dialog
.getByRole('button', { name: /^(add|add playlist)$/i })
.last()
.click();
await dialog.waitFor({ state: 'detached', timeout: 60_000 });
await page.waitForURL(/\/workspace\/stalker\/[^/]+\/vod/, {
timeout: 60_000,
+3
View File
@@ -108,6 +108,9 @@
"@iptvnator/ui/components/parental-lock-pin-dialog": [
"libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts"
],
"@iptvnator/ui/components/password-visibility-toggle": [
"libs/ui/components/src/lib/password-visibility-toggle/password-visibility-toggle.directive.ts"
],
"@iptvnator/ui/components/window-controls": [
"libs/ui/components/src/lib/window-controls/window-controls.component.ts"
],