feat(stalker): protocol-correct auth lifecycle (#1354)

This commit is contained in:
4gray authored and GitHub committed 2026-08-03 23:06:47 +02:00
1 parent e197409b10
commit d2a83164ec
56 files changed
+4807 -709

No files matched your search

@@ -6,6 +6,8 @@
import axios, { AxiosRequestConfig } from 'axios';
import { ipcMain } from 'electron';
import {
classifyStalkerAuthFailureBody,
createStalkerAuthFailureMarker,
PortalDebugEvent,
STALKER_REQUEST,
buildStalkerIdentityRequestContext,
@@ -111,6 +113,18 @@ ipcMain.handle(
throw httpError;
}
// The portal answers auth failures with HTTP 200 and a plain
// text/html body ("Authorization failed.", "Access denied.",
// "Unauthorized request."). Classify them here so the renderer
// receives a structured marker instead of pattern-matching raw
// response text.
const authFailureBody = classifyStalkerAuthFailureBody(
response.data
);
const responseData = authFailureBody
? createStalkerAuthFailureMarker(authFailureBody)
: response.data;
// Return the response data
if (
params.action === 'create_link' &&
@@ -136,12 +150,12 @@ ipcMain.handle(
durationMs: Date.now() - startedAt,
status: 'success',
request: debugRequest,
response: response.data,
response: responseData,
};
emitPortalDebugEvent(debugEvent);
}
return response.data;
return responseData;
} catch (error) {
if (payload.requestId) {
const debugEvent: PortalDebugEvent = {
+1 -1
View File
@@ -82,7 +82,7 @@ actually get wrong:
| `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow |
| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing |
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work |
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app drives this end to end: enter a username and password in the Stalker import dialog and it runs `do_auth`, then retries `get_profile` with `auth_second_step=1`. Covered by `stalker.e2e.ts` (`@stalker full portal authentication`) |
| `00:1A:79:00:00:09` | **gated-stream** | `create_link` returns a local `/stream/gated/…` URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials |
| `00:1A:79:00:00:0A` | **static-channel-cmd** | ITV rows carry a directly playable `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'` — a client honouring the flags must play them without calling `create_link` |
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
@@ -11,10 +11,9 @@ import { handleHandshake } from './handshake.handler';
/**
* Handler-level coverage for the authentication actions.
*
* The e2e suite drives the app against this server, but the login-required
* flow cannot be reached from the client yet (its `do_auth` path is dormant
* and sends empty credentials), so the scenario is pinned down here rather
* than only asserted in prose.
* The e2e suite drives the app through this flow end to end; these handler
* tests pin the SERVER half of the contract independently, so a client-side
* regression and a mock-side one cannot mask each other.
*
* Handlers are called directly instead of through the dispatcher: the
* dispatcher pulls in every content handler and therefore the faker-based
@@ -82,8 +81,8 @@ describe('stalker mock authentication handlers', () => {
})['token'] as string;
expect(token).toMatch(/^[0-9A-F]{32}$/);
// The app sends auth_second_step=1 on its very first profile request,
// so that parameter alone must not satisfy the scenario.
// A client could mislabel its first profile as the second auth step;
// that parameter alone must not satisfy the scenario.
expect(
invoke(
handleGetProfile,
@@ -33,10 +33,11 @@ export function handleGetProfile(
return;
}
// Gate on the actual do_auth completion, not on auth_second_step: the app
// sends auth_second_step=1 on its very first get_profile, so a parameter
// check would let the login-required flow be bypassed without ever
// exercising status 2 -> do_auth -> profile retry.
// Gate on the actual do_auth completion, not on auth_second_step. The
// client now sends `auth_second_step=0` first and `1` only on the retry
// after do_auth, so a parameter check would happen to work — but it would
// also silently pass for any client that mislabels its first profile,
// which is exactly the protocol mistake this scenario exists to catch.
if (scenario.requiresLogin && !hasCompletedDoAuth(mac)) {
res.json({
js: {
+243 -21
View File
@@ -48,6 +48,11 @@ import {
* `--project=chromium` alone (`.github/workflows/e2e-tests.yaml`). If a local
* all-project run shows a lone auth failure that passes on rerun, this is why;
* `--project=chromium` reproduces CI exactly.
*
* Most tests here tolerate that reset anyway — they re-authenticate, or assert
* that a NEW token appears. The token-reuse test cannot: its assertion IS that
* the portal session survives, so it uses per-project MACs held outside
* `OWNED_MACS`. See `AUTH_REUSE_MACS`.
*/
test.describe.configure({ mode: 'serial' });
@@ -82,6 +87,13 @@ const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
*/
const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
/**
* Login-required scenario MAC — get_profile answers status 2 until do_auth
* completes with non-empty credentials (empty credentials return {js:false},
* as the operator billing script would).
*/
const LOGIN_REQUIRED_MAC = '00:1A:79:00:00:08';
/**
* Dedicated MACs for the full-portal authentication tests. Mock state is keyed
* by MAC, so keeping these distinct from the content scenarios above means an
@@ -90,6 +102,25 @@ const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
*/
const AUTH_FLOW_MAC = '00:1A:79:AD:00:01';
const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03';
/**
* The token-reuse test needs the portal session to SURVIVE untouched between
* its import and its reload — that persistence is the whole assertion. Every
* other test here is reset-tolerant (they either re-authenticate or assert a
* new token), so they can share `OWNED_MACS`, which each `beforeEach` clears.
*
* `mode: 'serial'` only serializes within one project; the browser projects
* still run concurrently, so a sibling project's reset would rotate this
* session mid-test. Hence one MAC per project, and deliberately NOT in
* `OWNED_MACS`: nothing may reset them. Leftover state from an earlier run is
* harmless — the import negotiates and adopts its own token first.
*/
const AUTH_REUSE_MACS: Record<string, string> = {
chromium: '00:1A:79:AD:01:01',
firefox: '00:1A:79:AD:01:02',
webkit: '00:1A:79:AD:01:03',
};
const AUTH_REUSE_FALLBACK_MAC = '00:1A:79:AD:01:04';
/**
* Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for
* it, so no token is ever adopted and content requests fail permanently.
@@ -138,6 +169,7 @@ const OWNED_MACS = [
EMBEDDED_SERIES_MAC,
LEGACY_PAGINATION_MAC,
STATIC_CMD_MAC,
LOGIN_REQUIRED_MAC,
AUTH_FLOW_MAC,
AUTH_REAUTH_MAC,
AUTH_REJECTED_MAC,
@@ -212,9 +244,21 @@ async function addStalkerPortal(
*/
async function addFullStalkerPortal(
page: Page,
options: { name?: string; mac: string; expectContent?: boolean }
options: {
name?: string;
mac: string;
expectContent?: boolean;
username?: string;
password?: string;
}
): Promise<void> {
const { name = 'Full Stalker Portal', mac, expectContent = true } = options;
const {
name = 'Full Stalker Portal',
mac,
expectContent = true,
username,
password,
} = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
@@ -224,6 +268,12 @@ async function addFullStalkerPortal(
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), mac);
if (username !== undefined) {
await setInputValue(dialog.locator('input#username'), username);
}
if (password !== undefined) {
await setInputValue(dialog.locator('input#password'), password);
}
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
await expect(addButton).toBeEnabled({ timeout: 10_000 });
@@ -991,12 +1041,171 @@ test.describe('@stalker full portal authentication', () => {
.toBe(true);
});
test('never surfaces the portal plain-text auth failure as content', async ({
test('completes the documented login flow behind get_profile status 2', async ({
page,
}) => {
const requests = recordPortalRequests(page);
// The second auth step must only be claimed on the retry AFTER
// do_auth — a client that always sends auth_second_step=1 works
// against the mock but violates the documented protocol.
const profileSteps: string[] = [];
page.on('request', (request) => {
const url = new URL(request.url());
if (!url.pathname.endsWith('/stalker')) {
return;
}
if (url.searchParams.get('action') !== 'get_profile') {
return;
}
profileSteps.push(url.searchParams.get('auth_second_step') ?? '');
});
await addFullStalkerPortal(page, {
mac: LOGIN_REQUIRED_MAC,
username: 'user',
password: 'secret',
});
// The mock only adopts the token (and answers content) after do_auth
// completed with non-empty credentials, so rendered categories prove
// the whole status 2 -> do_auth -> profile retry chain ran.
await expect(page.locator('.category-item').first()).toBeVisible({
timeout: 30_000,
});
const actions = requests.map((entry) => entry.action);
expect(actions).toContain('do_auth');
expect(actions.indexOf('get_profile')).toBeLessThan(
actions.indexOf('do_auth')
);
expect(actions.lastIndexOf('get_profile')).toBeGreaterThan(
actions.indexOf('do_auth')
);
expect(profileSteps[0]).toBe('0');
expect(profileSteps).toContain('1');
// Content only flows once the token is adopted, which the mock does
// only after do_auth completed. Look AFTER the last get_profile:
// discovery's classification probe is itself a token-less
// `get_genres`, so the first CONTENT_ACTIONS hit is that probe.
const contentRequest = requests
.slice(actions.lastIndexOf('get_profile') + 1)
.find((entry) => CONTENT_ACTIONS.includes(entry.action));
expect(contentRequest).toBeDefined();
expect(contentRequest?.token).toBeTruthy();
});
test('explains a login-required portal and recovers once credentials are entered', async ({
page,
}) => {
// First attempt without credentials: the import must fail with the
// portal's actual reason, not a generic "check URL and MAC" error.
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), 'Login Portal');
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(
dialog.locator('input#macAddress'),
LOGIN_REQUIRED_MAC
);
const addButton = dialog.getByRole('button', {
name: 'Add',
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 });
// The dialog stays open so the user can add the credentials.
await expect(dialog).toBeVisible();
// Second attempt with credentials in the same dialog succeeds.
await setInputValue(dialog.locator('input#username'), 'user');
await setInputValue(dialog.locator('input#password'), 'secret');
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden({ timeout: 30_000 });
await page.waitForURL(/stalker.*vod/, { timeout: 30_000 });
await expect(page.locator('.category-item').first()).toBeVisible({
timeout: 30_000,
});
});
test('reuses the persisted token after a reload instead of re-running get_profile', async ({
page,
}, testInfo) => {
const requests = recordPortalRequests(page);
const mac =
AUTH_REUSE_MACS[testInfo.project.name] ?? AUTH_REUSE_FALLBACK_MAC;
await addFullStalkerPortal(page, { mac });
await expect
.poll(
() =>
requests.some(
(entry) =>
CONTENT_ACTIONS.includes(entry.action) &&
entry.token
),
{ timeout: 30_000 }
)
.toBe(true);
const sessionToken = requests.find(
(entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token
)?.token;
expect(sessionToken).toBeTruthy();
const requestCountBeforeReload = requests.length;
await page.reload();
// Wait on the UI rather than the request log: a reload restores the
// route, boots the app and re-authenticates before the first content
// request goes out, and rendered categories prove that whole chain
// finished (the portal only answers content for an adopted token).
await expect(page.locator('.category-item').first()).toBeVisible({
timeout: 60_000,
});
const afterReload = requests.slice(requestCountBeforeReload);
// Content came back under the SAME token, negotiated by re-presenting
// the persisted one in the handshake.
expect(
afterReload.filter(
(entry) =>
CONTENT_ACTIONS.includes(entry.action) &&
entry.token === sessionToken
).length
).toBeGreaterThan(0);
// The handshake re-presented the persisted token...
const reloadHandshake = afterReload.find(
(entry) => entry.action === 'handshake'
);
expect(reloadHandshake?.token).toBe(sessionToken);
// ...and because the portal returned it unchanged (idempotent
// handshake, still-adopted session), the profile round trip was
// skipped entirely.
expect(
afterReload.filter((entry) => entry.action === 'get_profile')
).toHaveLength(0);
});
test('refuses a rejected portal at import and never renders its plain-text failure', async ({
page,
request,
}) => {
const requests = recordPortalRequests(page);
// A MAC outside the Infomir OUI makes the strict endpoint answer
// get_profile with a bare {status:1}, so no token is ever adopted and
// every content request keeps returning the plain-text failure. Unlike
@@ -1014,25 +1223,38 @@ test.describe('@stalker full portal authentication', () => {
).json();
expect(failureBody.payload).toBe('Authorization failed.');
await addFullStalkerPortal(page, {
mac: AUTH_REJECTED_MAC,
expectContent: false,
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), 'Rejected Portal');
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(
dialog.locator('input#macAddress'),
AUTH_REJECTED_MAC
);
const addButton = dialog.getByRole('button', {
name: 'Add',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
// The app must have actually hit the failing portal...
await expect
.poll(
() =>
requests.filter((entry) =>
CONTENT_ACTIONS.includes(entry.action)
).length,
{ timeout: 30_000 }
)
.toBeGreaterThan(0);
// `status: 1` means the portal refused this device, so the import must
// 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).toBeVisible();
await expect(page).not.toHaveURL(/stalker/);
// ...and must never render the raw portal response as content. A
// portal answers auth failures with HTTP 200 + plain text, so an app
// that trusts the status code would happily paint these strings.
// And the raw portal response is never painted as UI. A portal answers
// auth failures with HTTP 200 + plain text, so an app that trusts the
// status code would happily render these strings.
await expect(page.locator('body')).not.toContainText(
'Authorization failed.'
);
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "رابط الخادم",
"URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com/stalker_portal)",
"ADD": "إضافة",
"VALIDATING": "جارٍ التحقق من البوابة..."
"VALIDATING": "جارٍ التحقق من البوابة...",
"CREDENTIALS_HINT": "مطلوب فقط عندما تطلب البوابة اسم مستخدم وكلمة مرور",
"AUTH_FAILED": "فشلت المصادقة مع البوابة. تحقق من الرابط وعنوان MAC.",
"LOGIN_REQUIRED": "تتطلب هذه البوابة اسم مستخدم وكلمة مرور. املأ حقلي اسم المستخدم وكلمة المرور وأعد المحاولة.",
"LOGIN_REJECTED": "رفضت البوابة اسم المستخدم وكلمة المرور.",
"PORTAL_REFUSED": "رفضت البوابة الوصول لهذا الجهاز.",
"PORTAL_MESSAGE": "أفادت البوابة: {{message}}"
},
"FILTER_BY_NAME": "التصفية حسب الاسم",
"FILTER_AND_SORT": "تصفية وفرز",
@@ -957,6 +963,7 @@
"TITLE": "لم يتم تحديد فئة",
"DESCRIPTION": "يرجى اختيار فئة لعرض المحتوى"
},
"STALKER_LOGIN_REQUIRED": "تتطلب البوابة اسم مستخدم وكلمة مرور. أعد استيراد البوابة واملأ حقلي اسم المستخدم وكلمة المرور.",
"PLAYLIST_SETTINGS": "إعدادات القائمة",
"HOME": "الرئيسية",
"DELETE": "حذف"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "رابط الخادم",
"URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com/c ولا https://example.com/stalker_portal/c)",
"ADD": "زيد",
"VALIDATING": "جاري التحقق من البوابة..."
"VALIDATING": "جاري التحقق من البوابة...",
"CREDENTIALS_HINT": "خاصين غير إذا البوابة كتطلب اسم المستخدم وكلمة المرور",
"AUTH_FAILED": "فشل تسجيل الدخول للبوابة. تحقق من الرابط وعنوان MAC.",
"LOGIN_REQUIRED": "هاد البوابة كتطلب اسم المستخدم وكلمة المرور. عمّر خانات اسم المستخدم وكلمة المرور وحاول مرة أخرى.",
"LOGIN_REJECTED": "البوابة رفضات اسم المستخدم وكلمة المرور.",
"PORTAL_REFUSED": "البوابة رفضات الوصول لهاد الجهاز.",
"PORTAL_MESSAGE": "البوابة قالت: {{message}}"
},
"FILTER_BY_NAME": "فلتر بالاسم",
"FILTER_AND_SORT": "فلتر وترتيب",
@@ -957,6 +963,7 @@
"TITLE": "ما كاين حتى فئة متختارة",
"DESCRIPTION": "عفاك اختار فئة باش تشوف المحتوى"
},
"STALKER_LOGIN_REQUIRED": "البوابة كتطلب اسم المستخدم وكلمة المرور. عاود استيراد البوابة وعمّر خانات اسم المستخدم وكلمة المرور.",
"PLAYLIST_SETTINGS": "إعدادات قائمة التشغيل",
"HOME": "الرئيسية",
"DELETE": "حذف"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL сервера",
"URL_VALIDATION_ERROR": "Павінен быць сапраўдны URL-адрас з пратаколам (напрыклад, http://example.com/c або https://example.com/stalker_portal/c).",
"ADD": "Дадаць",
"VALIDATING": "Праверка партала..."
"VALIDATING": "Праверка партала...",
"CREDENTIALS_HINT": "Патрэбна толькі тады, калі партал запытвае лагін і пароль",
"AUTH_FAILED": "Не ўдалося аўтэнтыфікавацца на партале. Праверце URL і MAC-адрас.",
"LOGIN_REQUIRED": "Гэты партал патрабуе лагін і пароль. Запоўніце палі імя карыстальніка і пароля і паспрабуйце яшчэ раз.",
"LOGIN_REJECTED": "Партал адхіліў лагін і пароль.",
"PORTAL_REFUSED": "Партал адмовіў у доступе для гэтай прылады.",
"PORTAL_MESSAGE": "Партал паведаміў: {{message}}"
},
"FILTER_BY_NAME": "Фільтраваць па імені",
"FILTER_AND_SORT": "Фільтр і сартаванне",
@@ -957,6 +963,7 @@
"TITLE": "Катэгорыя не выбрана",
"DESCRIPTION": "Калі ласка, выберыце катэгорыю, каб убачыць кантэнт"
},
"STALKER_LOGIN_REQUIRED": "Партал патрабуе лагін і пароль. Імпартуйце партал яшчэ раз і запоўніце палі імя карыстальніка і пароля.",
"PLAYLIST_SETTINGS": "Налады плэйліста",
"HOME": "На галоўную",
"DELETE": "Выдаліць"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "Server-URL",
"URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com/c oder https://example.com/stalker_portal/c)",
"ADD": "Hinzufügen",
"VALIDATING": "Portal wird überprüft …"
"VALIDATING": "Portal wird überprüft …",
"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.",
"LOGIN_REQUIRED": "Dieses Portal erfordert Benutzername und Passwort. Füllen Sie die Felder Benutzername und Passwort aus und versuchen Sie es erneut.",
"LOGIN_REJECTED": "Das Portal hat Benutzername und Passwort abgelehnt.",
"PORTAL_REFUSED": "Das Portal hat den Zugriff für dieses Gerät verweigert.",
"PORTAL_MESSAGE": "Das Portal meldet: {{message}}"
},
"FILTER_BY_NAME": "Nach Namen filtern",
"FILTER_AND_SORT": "Filtern & Sortieren",
@@ -957,6 +963,7 @@
"TITLE": "Keine Kategorie ausgewählt",
"DESCRIPTION": "Bitte wählen Sie eine Kategorie aus, um den Inhalt anzuzeigen"
},
"STALKER_LOGIN_REQUIRED": "Das Portal erfordert Benutzername und Passwort. Importieren Sie das Portal erneut und füllen Sie die Felder Benutzername und Passwort aus.",
"PLAYLIST_SETTINGS": "Playlist-Einstellungen",
"HOME": "Startseite",
"DELETE": "Löschen"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "Διεύθηνση URL σέρβερ",
"URL_VALIDATION_ERROR": "Θα πρέπει να είναι μια έγκυρη διεύθυνση URL με πρωτόκολλο (π.χ. http://example.com/c ή https://example.com/stalker_portal/c)",
"ADD": "Προσθήκη",
"VALIDATING": "Επικύρωση πύλης..."
"VALIDATING": "Επικύρωση πύλης...",
"CREDENTIALS_HINT": "Απαιτείται μόνο όταν η πύλη ζητά όνομα χρήστη και κωδικό πρόσβασης",
"AUTH_FAILED": "Η ταυτοποίηση με την πύλη απέτυχε. Ελέγξτε τη διεύθυνση URL και τη διεύθυνση MAC.",
"LOGIN_REQUIRED": "Αυτή η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης και δοκιμάστε ξανά.",
"LOGIN_REJECTED": "Η πύλη απέρριψε το όνομα χρήστη και τον κωδικό πρόσβασης.",
"PORTAL_REFUSED": "Η πύλη αρνήθηκε την πρόσβαση για αυτήν τη συσκευή.",
"PORTAL_MESSAGE": "Η πύλη ανέφερε: {{message}}"
},
"FILTER_BY_NAME": "Φιλτράρισμα κατά όνομα",
"FILTER_AND_SORT": "Φιλτράρισμα & Ταξινόμηση",
@@ -957,6 +963,7 @@
"TITLE": "Δεν έχει επιλεγεί κατηγορία",
"DESCRIPTION": "Επιλέξτε μια κατηγορία για να δείτε το περιεχόμενο"
},
"STALKER_LOGIN_REQUIRED": "Η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Εισαγάγετε ξανά την πύλη και συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης.",
"PLAYLIST_SETTINGS": "Ρυθμίσεις λίστας αναπαραγωγής",
"HOME": "Αρχικό",
"DELETE": "Διαγραφή"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "Server URL",
"URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com/c or https://example.com/stalker_portal/c)",
"ADD": "Add",
"VALIDATING": "Validating portal..."
"VALIDATING": "Validating portal...",
"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.",
"LOGIN_REQUIRED": "This portal requires a login and password. Fill in the username and password fields and try again.",
"LOGIN_REJECTED": "The portal rejected the login and password.",
"PORTAL_REFUSED": "The portal refused access for this device.",
"PORTAL_MESSAGE": "The portal reported: {{message}}"
},
"FILTER_BY_NAME": "Filter by name",
"FILTER_AND_SORT": "Filter & Sort",
@@ -957,6 +963,7 @@
"TITLE": "No category selected",
"DESCRIPTION": "Please select a category to see the content"
},
"STALKER_LOGIN_REQUIRED": "The portal requires a login and password. Import the portal again and fill in the username and password fields.",
"PLAYLIST_SETTINGS": "Playlist Settings",
"HOME": "Home",
"DELETE": "Delete"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL del servidor",
"URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com/c o https://example.com/stalker_portal/c).",
"ADD": "Agregar",
"VALIDATING": "Validando portal…"
"VALIDATING": "Validando portal…",
"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.",
"LOGIN_REQUIRED": "Este portal requiere usuario y contraseña. Completa los campos de nombre de usuario y contraseña e inténtalo de nuevo.",
"LOGIN_REJECTED": "El portal rechazó el usuario y la contraseña.",
"PORTAL_REFUSED": "El portal denegó el acceso a este dispositivo.",
"PORTAL_MESSAGE": "El portal informó: {{message}}"
},
"FILTER_BY_NAME": "Filtrar por nombre",
"FILTER_AND_SORT": "Filtrar y ordenar",
@@ -957,6 +963,7 @@
"TITLE": "Ninguna categoría seleccionada",
"DESCRIPTION": "Selecciona una categoría para ver el contenido"
},
"STALKER_LOGIN_REQUIRED": "El portal requiere usuario y contraseña. Vuelve a importar el portal y completa los campos de nombre de usuario y contraseña.",
"PLAYLIST_SETTINGS": "Configuración de lista de reproducción",
"HOME": "Hogar",
"DELETE": "Borrar"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL du serveur",
"URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com/c ou https://example.com/stalker_portal/c)",
"ADD": "Ajouter",
"VALIDATING": "Validation du portail…"
"VALIDATING": "Validation du portail…",
"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.",
"LOGIN_REQUIRED": "Ce portail nécessite un identifiant et un mot de passe. Renseignez les champs nom d'utilisateur et mot de passe, puis réessayez.",
"LOGIN_REJECTED": "Le portail a rejeté l'identifiant et le mot de passe.",
"PORTAL_REFUSED": "Le portail a refusé l'accès pour cet appareil.",
"PORTAL_MESSAGE": "Le portail a signalé : {{message}}"
},
"FILTER_BY_NAME": "Filtrer par nom",
"FILTER_AND_SORT": "Filtrer et trier",
@@ -957,6 +963,7 @@
"TITLE": "Aucune catégorie sélectionnée",
"DESCRIPTION": "Veuillez sélectionner une catégorie pour voir le contenu"
},
"STALKER_LOGIN_REQUIRED": "Le portail nécessite un identifiant et un mot de passe. Importez à nouveau le portail et renseignez les champs nom d'utilisateur et mot de passe.",
"PLAYLIST_SETTINGS": "Paramètres de la liste",
"HOME": "Accueil",
"DELETE": "Supprimer"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "Kiszolgáló URL-címe",
"URL_VALIDATION_ERROR": "Adjon meg protokollt is tartalmazó, érvényes URL-címet (például http://example.com/c vagy https://example.com/stalker_portal/c)",
"ADD": "Hozzáadás",
"VALIDATING": "Portál ellenőrzése…"
"VALIDATING": "Portál ellenőrzése…",
"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.",
"LOGIN_REQUIRED": "Ez a portál felhasználónevet és jelszót igényel. Töltse ki a felhasználónév és a jelszó mezőt, majd próbálja újra.",
"LOGIN_REJECTED": "A portál elutasította a felhasználónevet és a jelszót.",
"PORTAL_REFUSED": "A portál megtagadta a hozzáférést ettől az eszköztől.",
"PORTAL_MESSAGE": "A portál a következőt jelezte: {{message}}"
},
"FILTER_BY_NAME": "Szűrés név alapján",
"FILTER_AND_SORT": "Szűrés és rendezés",
@@ -957,6 +963,7 @@
"TITLE": "Nincs kiválasztott kategória",
"DESCRIPTION": "A tartalom megjelenítéséhez válasszon egy kategóriát."
},
"STALKER_LOGIN_REQUIRED": "A portál felhasználónevet és jelszót igényel. Importálja újra a portált, és töltse ki a felhasználónév és a jelszó mezőt.",
"PLAYLIST_SETTINGS": "Lejátszási lista beállításai",
"HOME": "Kezdőlap",
"DELETE": "Törlés"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL server",
"URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it/c o https://esempio.it/stalker_portal/c)",
"ADD": "Aggiungi",
"VALIDATING": "Validazione del portale..."
"VALIDATING": "Validazione del portale...",
"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.",
"LOGIN_REQUIRED": "Questo portale richiede nome utente e password. Compila i campi nome utente e password e riprova.",
"LOGIN_REJECTED": "Il portale ha rifiutato il nome utente e la password.",
"PORTAL_REFUSED": "Il portale ha negato l'accesso a questo dispositivo.",
"PORTAL_MESSAGE": "Il portale ha segnalato: {{message}}"
},
"FILTER_BY_NAME": "Filtra per nome",
"FILTER_AND_SORT": "Filtra e ordina",
@@ -957,6 +963,7 @@
"TITLE": "Nessuna categoria selezionata",
"DESCRIPTION": "Seleziona una categoria per vedere i contenuti"
},
"STALKER_LOGIN_REQUIRED": "Il portale richiede nome utente e password. Importa di nuovo il portale e compila i campi nome utente e password.",
"PLAYLIST_SETTINGS": "Impostazioni Playlist",
"HOME": "Home",
"DELETE": "Elimina"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "サーバーURL",
"URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com/c または https://example.com/stalker_portal/c)",
"ADD": "追加",
"VALIDATING": "ポータルを検証中..."
"VALIDATING": "ポータルを検証中...",
"CREDENTIALS_HINT": "ポータルがユーザー名とパスワードを要求する場合のみ必要",
"AUTH_FAILED": "ポータルでの認証に失敗しました。URLとMACアドレスを確認してください。",
"LOGIN_REQUIRED": "このポータルにはユーザー名とパスワードが必要です。ユーザー名とパスワードの欄を入力して、もう一度お試しください。",
"LOGIN_REJECTED": "ポータルがユーザー名とパスワードを拒否しました。",
"PORTAL_REFUSED": "ポータルがこのデバイスのアクセスを拒否しました。",
"PORTAL_MESSAGE": "ポータルからの報告:{{message}}"
},
"FILTER_BY_NAME": "名前でフィルター",
"FILTER_AND_SORT": "フィルターと並び替え",
@@ -957,6 +963,7 @@
"TITLE": "カテゴリーが選択されていません",
"DESCRIPTION": "コンテンツを表示するにはカテゴリーを選択してください"
},
"STALKER_LOGIN_REQUIRED": "ポータルにはユーザー名とパスワードが必要です。ポータルを再インポートして、ユーザー名とパスワードの欄を入力してください。",
"PLAYLIST_SETTINGS": "プレイリスト設定",
"HOME": "ホーム",
"DELETE": "削除"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "서버 URL",
"URL_VALIDATION_ERROR": "프로토콜이 포함된 유효한 URL이어야 합니다(예: http://example.com/c 또는 https://example.com/stalker_portal/c).",
"ADD": "추가하다",
"VALIDATING": "포털을 검증하는 중..."
"VALIDATING": "포털을 검증하는 중...",
"CREDENTIALS_HINT": "포털이 사용자 이름과 비밀번호를 요구하는 경우에만 필요",
"AUTH_FAILED": "포털 인증에 실패했습니다. URL과 MAC 주소를 확인하세요.",
"LOGIN_REQUIRED": "이 포털에는 사용자 이름과 비밀번호가 필요합니다. 사용자 이름과 비밀번호 필드를 입력한 후 다시 시도하세요.",
"LOGIN_REJECTED": "포털이 사용자 이름과 비밀번호를 거부했습니다.",
"PORTAL_REFUSED": "포털이 이 기기의 접근을 거부했습니다.",
"PORTAL_MESSAGE": "포털에서 보고한 내용: {{message}}"
},
"FILTER_BY_NAME": "이름으로 필터",
"FILTER_AND_SORT": "필터 및 정렬",
@@ -957,6 +963,7 @@
"TITLE": "선택된 카테고리가 없습니다",
"DESCRIPTION": "콘텐츠를 보려면 카테고리를 선택하세요"
},
"STALKER_LOGIN_REQUIRED": "포털에 사용자 이름과 비밀번호가 필요합니다. 포털을 다시 가져온 후 사용자 이름과 비밀번호 필드를 입력하세요.",
"PLAYLIST_SETTINGS": "재생목록 설정",
"HOME": "홈",
"DELETE": "삭제"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "Server URL",
"URL_VALIDATION_ERROR": "Moet een geldige URL zijn met protocol (bijv. http://example.com/c o https://example.com/stalker_portal/c)",
"ADD": "Toevoegen",
"VALIDATING": "Portaal valideren..."
"VALIDATING": "Portaal valideren...",
"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.",
"LOGIN_REQUIRED": "Dit portaal vereist een gebruikersnaam en wachtwoord. Vul de velden gebruikersnaam en wachtwoord in en probeer het opnieuw.",
"LOGIN_REJECTED": "Het portaal heeft de gebruikersnaam en het wachtwoord geweigerd.",
"PORTAL_REFUSED": "Het portaal heeft de toegang voor dit apparaat geweigerd.",
"PORTAL_MESSAGE": "Het portaal meldde: {{message}}"
},
"FILTER_BY_NAME": "Filter op naam",
"FILTER_AND_SORT": "Filteren & sorteren",
@@ -957,6 +963,7 @@
"TITLE": "Geen categorie geselecteerd",
"DESCRIPTION": "Selecteer een categorie om de inhoud te bekijken"
},
"STALKER_LOGIN_REQUIRED": "Het portaal vereist een gebruikersnaam en wachtwoord. Importeer het portaal opnieuw en vul de velden gebruikersnaam en wachtwoord in.",
"PLAYLIST_SETTINGS": "Afspeellijst instellingen",
"HOME": "Home",
"DELETE": "Verwijderen"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL serwera",
"URL_VALIDATION_ERROR": "Powinien to być poprawny URL z protokołem (np. http://example.com/c lub https://example.com/stalker_portal/c)",
"ADD": "Dodaj",
"VALIDATING": "Walidacja portalu..."
"VALIDATING": "Walidacja portalu...",
"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.",
"LOGIN_REQUIRED": "Ten portal wymaga loginu i hasła. Wypełnij pola nazwy użytkownika i hasła i spróbuj ponownie.",
"LOGIN_REJECTED": "Portal odrzucił login i hasło.",
"PORTAL_REFUSED": "Portal odmówił dostępu temu urządzeniu.",
"PORTAL_MESSAGE": "Portal zgłosił: {{message}}"
},
"FILTER_BY_NAME": "Filtruj według nazwy",
"FILTER_AND_SORT": "Filtruj i sortuj",
@@ -957,6 +963,7 @@
"TITLE": "Nie wybrano kategorii",
"DESCRIPTION": "Wybierz kategorię, aby zobaczyć treści"
},
"STALKER_LOGIN_REQUIRED": "Portal wymaga loginu i hasła. Zaimportuj portal ponownie i wypełnij pola nazwy użytkownika i hasła.",
"PLAYLIST_SETTINGS": "Ustawienia listy odtwarzania",
"HOME": "Strona główna",
"DELETE": "Usuń"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL do servidor",
"URL_VALIDATION_ERROR": "Deve ser uma URL válida com protocolo (por exemplo, http://example.com/c ou https://example.com/stalker_portal/c)",
"ADD": "Adicionar",
"VALIDATING": "Validando portal..."
"VALIDATING": "Validando portal...",
"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.",
"LOGIN_REQUIRED": "Este portal exige login e senha. Preencha os campos de nome de usuário e senha e tente novamente.",
"LOGIN_REJECTED": "O portal rejeitou o login e a senha.",
"PORTAL_REFUSED": "O portal recusou o acesso para este dispositivo.",
"PORTAL_MESSAGE": "O portal informou: {{message}}"
},
"FILTER_BY_NAME": "Filtrar por nome",
"FILTER_AND_SORT": "Filtrar e ordenar",
@@ -957,6 +963,7 @@
"TITLE": "Nenhuma categoria selecionada",
"DESCRIPTION": "Selecione uma categoria para ver o conteúdo"
},
"STALKER_LOGIN_REQUIRED": "O portal exige login e senha. Importe o portal novamente e preencha os campos de nome de usuário e senha.",
"PLAYLIST_SETTINGS": "Configurações da playlist",
"HOME": "Início",
"DELETE": "Excluir"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL сервера",
"URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com/c или https://example.com/stalker_portal/c).",
"ADD": "Добавить",
"VALIDATING": "Проверка портала…"
"VALIDATING": "Проверка портала…",
"CREDENTIALS_HINT": "Требуется, только если портал запрашивает логин и пароль",
"AUTH_FAILED": "Не удалось авторизоваться на портале. Проверьте URL и MAC-адрес.",
"LOGIN_REQUIRED": "Этот портал требует логин и пароль. Заполните поля имени пользователя и пароля и повторите попытку.",
"LOGIN_REJECTED": "Портал отклонил логин и пароль.",
"PORTAL_REFUSED": "Портал отказал в доступе этому устройству.",
"PORTAL_MESSAGE": "Портал сообщил: {{message}}"
},
"FILTER_BY_NAME": "Фильтровать по имени",
"FILTER_AND_SORT": "Фильтр и сортировка",
@@ -957,6 +963,7 @@
"TITLE": "Категория не выбрана",
"DESCRIPTION": "Пожалуйста, выберите категорию для просмотра содержимого"
},
"STALKER_LOGIN_REQUIRED": "Портал требует логин и пароль. Импортируйте портал заново и заполните поля имени пользователя и пароля.",
"PLAYLIST_SETTINGS": "Настройки плейлиста",
"HOME": "На главную",
"DELETE": "Удалить"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "Sunucu URL'si",
"URL_VALIDATION_ERROR": "Geçerli bir URL olmalıdır (örn. http://example.com/c veya https://example.com/stalker_portal/c)",
"ADD": "Ekle",
"VALIDATING": "Portal doğrulanıyor..."
"VALIDATING": "Portal doğrulanıyor...",
"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.",
"LOGIN_REQUIRED": "Bu portal kullanıcı adı ve parola gerektiriyor. Kullanıcı adı ve parola alanlarını doldurup tekrar deneyin.",
"LOGIN_REJECTED": "Portal, kullanıcı adı ve parolayı reddetti.",
"PORTAL_REFUSED": "Portal bu cihaz için erişimi reddetti.",
"PORTAL_MESSAGE": "Portal şunu bildirdi: {{message}}"
},
"FILTER_BY_NAME": "İsime göre filtrele",
"FILTER_AND_SORT": "Filtrele ve Sırala",
@@ -957,6 +963,7 @@
"TITLE": "Kategori seçilmedi",
"DESCRIPTION": "İçeriği görmek için lütfen bir kategori seçin"
},
"STALKER_LOGIN_REQUIRED": "Portal, kullanıcı adı ve parola gerektiriyor. Portalı yeniden içe aktarın ve kullanıcı adı ile parola alanlarını doldurun.",
"PLAYLIST_SETTINGS": "Oynatma Listesi Ayarları",
"HOME": "Ana Sayfa",
"DELETE": "Sil"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "服务器 URL",
"URL_VALIDATION_ERROR": "应该是带有协议的有效网址(例如 http://example.com/c 或 https://example.com/stalker_portal/c)",
"ADD": "添加",
"VALIDATING": "正在验证门户…"
"VALIDATING": "正在验证门户…",
"CREDENTIALS_HINT": "仅在门户要求登录名和密码时才需要填写",
"AUTH_FAILED": "门户身份验证失败。请检查 URL 和 MAC 地址。",
"LOGIN_REQUIRED": "此门户需要登录名和密码。请填写用户名和密码字段后重试。",
"LOGIN_REJECTED": "门户拒绝了该登录名和密码。",
"PORTAL_REFUSED": "门户拒绝了此设备的访问。",
"PORTAL_MESSAGE": "门户返回信息:{{message}}"
},
"FILTER_BY_NAME": "按名称筛选",
"FILTER_AND_SORT": "筛选与排序",
@@ -957,6 +963,7 @@
"TITLE": "未选择分类",
"DESCRIPTION": "请选择一个分类以查看内容"
},
"STALKER_LOGIN_REQUIRED": "门户需要登录名和密码。请重新导入该门户并填写用户名和密码字段。",
"PLAYLIST_SETTINGS": "播放列表设置",
"HOME": "首页",
"DELETE": "删除"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "伺服器網址",
"URL_VALIDATION_ERROR": "必須是有效的網址並包含協定(例如 http://example.com/c 或 https://example.com/stalker_portal/c)",
"ADD": "新增",
"VALIDATING": "正在驗證入口網站..."
"VALIDATING": "正在驗證入口網站...",
"CREDENTIALS_HINT": "僅在入口網站要求登入名稱與密碼時才需填寫",
"AUTH_FAILED": "入口網站驗證失敗。請檢查網址與 MAC 位址。",
"LOGIN_REQUIRED": "此入口網站需要登入名稱與密碼。請填寫使用者名稱與密碼欄位後重試。",
"LOGIN_REJECTED": "入口網站拒絕了此登入名稱與密碼。",
"PORTAL_REFUSED": "入口網站拒絕了此裝置的存取。",
"PORTAL_MESSAGE": "入口網站回報:{{message}}"
},
"FILTER_BY_NAME": "依名稱篩選",
"FILTER_AND_SORT": "篩選與排序",
@@ -957,6 +963,7 @@
"TITLE": "未選擇類別",
"DESCRIPTION": "請選擇一個類別以查看內容"
},
"STALKER_LOGIN_REQUIRED": "入口網站需要登入名稱與密碼。請重新匯入入口網站並填寫使用者名稱與密碼欄位。",
"PLAYLIST_SETTINGS": "播放清單設定",
"HOME": "首頁",
"DELETE": "刪除"