mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge remote-tracking branch 'origin/master' into claude/iptvnator-vod-hero-redesign-2c8950
This commit is contained in:
commit
042016e578
99 files changed
+5103
-429
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: perf
|
||||
area: electron
|
||||
---
|
||||
|
||||
On macOS and Linux the app shows your sources sooner after launch: looking up
|
||||
the PATH from your login shell (used to find MPV and VLC) no longer freezes the
|
||||
app while the shell starts, which took one to two seconds with a typical zsh
|
||||
setup.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
---
|
||||
|
||||
Watch progress has one colour again: your theme's accent on dashboard cards
|
||||
and catalog posters, and the player's blue everywhere inside the player, so
|
||||
the next episode's bar no longer turns red in the Up Next rail and the
|
||||
fullscreen episode list.
|
||||
@@ -390,8 +390,12 @@ jobs:
|
||||
- name: Typecheck Jest spec programs
|
||||
run: pnpm run typecheck:spec:test && pnpm run typecheck:spec
|
||||
|
||||
- name: Check i18n drift
|
||||
run: pnpm run i18n:check
|
||||
# Fails on missing or extra keys and on values identical to
|
||||
# English that tools/i18n/identical-en-baseline.json does not list.
|
||||
# The baseline only changes through a reviewed
|
||||
# `pnpm run i18n:baseline:update`; CI never rewrites it.
|
||||
- name: Check i18n drift and untranslated values
|
||||
run: pnpm run i18n:validate
|
||||
|
||||
# Node specs for the benchmark and journey harness (about ten
|
||||
# seconds). They live in the E2E app, which the unit coverage scope
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ function measurement(
|
||||
},
|
||||
};
|
||||
const ipc: JourneyMainIpcCaptureState = {
|
||||
ambiguousTimelineCompletions: 0,
|
||||
callsAfterSentinel: 3,
|
||||
callsBeforeStart: 6,
|
||||
callsBeforeSentinel: 4,
|
||||
@@ -116,6 +117,16 @@ function measurement(
|
||||
senderIds: [1],
|
||||
sentinel: { occurrences: 1, receivedEpochMs: 10_351 },
|
||||
start: { occurrences: 1, receivedEpochMs: 10_001 },
|
||||
timeline: [
|
||||
{ method: 'setUserAgent', phase: 'start' },
|
||||
{ method: 'setUserAgent', phase: 'end' },
|
||||
{ method: 'xtreamRequest', phase: 'start' },
|
||||
{ method: 'getEpgMapping', phase: 'start' },
|
||||
{ method: 'getEpgMapping', phase: 'end' },
|
||||
{ method: 'updateRemoteControlStatus', phase: 'start' },
|
||||
{ method: 'updateRemoteControlStatus', phase: 'end' },
|
||||
{ method: 'xtreamRequest', phase: 'end' },
|
||||
],
|
||||
unmatchedCompletions: 0,
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,7 +6,10 @@ jest.mock('electron', () => ({
|
||||
|
||||
const mockEmbeddedMpvService = {
|
||||
createSession: jest.fn(),
|
||||
prepareAddon: jest.fn(),
|
||||
getSupport: jest.fn(),
|
||||
willProbeLinuxMpvExecutable: jest.fn(() => false),
|
||||
forgetLinuxMpvExecutableProbe: jest.fn(),
|
||||
setPaused: jest.fn(),
|
||||
};
|
||||
const mockSessionOptions = {
|
||||
@@ -21,10 +24,20 @@ jest.mock('../services/embedded-mpv-native.service', () => ({
|
||||
jest.mock('../services/embedded-mpv-session-options', () => ({
|
||||
readEmbeddedMpvSessionOptions: () => mockSessionOptions,
|
||||
}));
|
||||
const mockWaitForLoginShellPath = jest.fn(() => Promise.resolve(true));
|
||||
let settleLookup: () => void = () => undefined;
|
||||
const mockLookupSettled = new Promise<void>((resolve) => {
|
||||
settleLookup = resolve;
|
||||
});
|
||||
jest.mock('../startup/login-shell-path', () => ({
|
||||
waitForLoginShellPath: () => mockWaitForLoginShellPath(),
|
||||
whenLoginShellPathSettled: () => mockLookupSettled,
|
||||
}));
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
EMBEDDED_MPV_CREATE_SESSION,
|
||||
EMBEDDED_MPV_PREPARE,
|
||||
EMBEDDED_MPV_SET_PAUSED,
|
||||
EMBEDDED_MPV_SUPPORT,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -55,6 +68,91 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
|
||||
mockEmbeddedMpvService.setPaused.mockReset();
|
||||
});
|
||||
|
||||
describe('support checks and the login shell PATH', () => {
|
||||
afterEach(() => {
|
||||
mockWaitForLoginShellPath.mockClear();
|
||||
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReset();
|
||||
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it.each([EMBEDDED_MPV_SUPPORT, EMBEDDED_MPV_PREPARE])(
|
||||
'%s waits for the lookup before the bare-name mpv probe',
|
||||
async (channel) => {
|
||||
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
|
||||
true
|
||||
);
|
||||
let settle: (settled: boolean) => void = () => undefined;
|
||||
mockWaitForLoginShellPath.mockReturnValueOnce(
|
||||
new Promise<boolean>((resolve) => {
|
||||
settle = resolve;
|
||||
})
|
||||
);
|
||||
mockEmbeddedMpvService.getSupport.mockReturnValue({
|
||||
supported: true,
|
||||
});
|
||||
mockEmbeddedMpvService.prepareAddon.mockReturnValue({
|
||||
supported: true,
|
||||
});
|
||||
|
||||
const support = getIpcMainHandler(channel)({});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(
|
||||
mockEmbeddedMpvService.getSupport
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockEmbeddedMpvService.prepareAddon
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
settle(true);
|
||||
await expect(support).resolves.toEqual({ supported: true });
|
||||
expect(
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
|
||||
).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('re-probes mpv once a lookup that ran out finally answers', async () => {
|
||||
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
|
||||
true
|
||||
);
|
||||
mockWaitForLoginShellPath.mockResolvedValueOnce(false);
|
||||
mockEmbeddedMpvService.getSupport.mockReturnValue({
|
||||
supported: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({})
|
||||
).resolves.toEqual({ supported: false });
|
||||
expect(
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
settleLookup();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not wait when no probe runs, nor for session calls', async () => {
|
||||
mockEmbeddedMpvService.getSupport.mockReturnValue({
|
||||
supported: true,
|
||||
engine: 'frame-copy',
|
||||
});
|
||||
mockEmbeddedMpvService.createSession.mockReturnValue({ id: 's' });
|
||||
|
||||
await getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({});
|
||||
await getIpcMainHandler(EMBEDDED_MPV_CREATE_SESSION)(
|
||||
{},
|
||||
{ x: 0, y: 0, width: 1, height: 1 }
|
||||
);
|
||||
|
||||
expect(mockWaitForLoginShellPath).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a session with the options read from the settings mirror', async () => {
|
||||
const session = { id: 'session-1', status: 'idle' };
|
||||
mockEmbeddedMpvService.createSession.mockReturnValue(session);
|
||||
|
||||
@@ -33,6 +33,10 @@ import {
|
||||
embeddedMpvNativeService,
|
||||
} from '../services/embedded-mpv-native.service';
|
||||
import { readEmbeddedMpvSessionOptions } from '../services/embedded-mpv-session-options';
|
||||
import {
|
||||
waitForLoginShellPath,
|
||||
whenLoginShellPathSettled,
|
||||
} from '../startup/login-shell-path';
|
||||
|
||||
export default class EmbeddedMpvEvents {
|
||||
static bootstrapEmbeddedMpvEvents(): Electron.IpcMain {
|
||||
@@ -64,9 +68,34 @@ function handleEmbeddedMpv<Args extends unknown[]>(
|
||||
});
|
||||
}
|
||||
|
||||
handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () => getService().getSupport());
|
||||
/**
|
||||
* On Linux, support (and prepare, which checks support first) may run
|
||||
* `mpv --version` by bare name and cache the result, so it must see the
|
||||
* login shell PATH. Only that probe waits; nothing else here spawns by name.
|
||||
*/
|
||||
async function afterLoginShellPathIfProbing<T>(check: () => T): Promise<T> {
|
||||
if (
|
||||
!getService().willProbeLinuxMpvExecutable() ||
|
||||
(await waitForLoginShellPath())
|
||||
) {
|
||||
return check();
|
||||
}
|
||||
// The lookup ran out of budget, so this probe sees the inherited PATH.
|
||||
// Once the shell does answer, the result is probed again.
|
||||
const result = check();
|
||||
void whenLoginShellPathSettled().then(() =>
|
||||
getService().forgetLinuxMpvExecutableProbe()
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
handleEmbeddedMpv(EMBEDDED_MPV_PREPARE, () => getService().prepareAddon());
|
||||
handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () =>
|
||||
afterLoginShellPathIfProbing(() => getService().getSupport())
|
||||
);
|
||||
|
||||
handleEmbeddedMpv(EMBEDDED_MPV_PREPARE, () =>
|
||||
afterLoginShellPathIfProbing(() => getService().prepareAddon())
|
||||
);
|
||||
|
||||
handleEmbeddedMpv(
|
||||
EMBEDDED_MPV_CREATE_SESSION,
|
||||
|
||||
@@ -4,6 +4,11 @@ jest.mock('electron', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('fs', () => {
|
||||
const actual = jest.requireActual<typeof import('fs')>('fs');
|
||||
return { ...actual, existsSync: jest.fn(actual.existsSync) };
|
||||
});
|
||||
|
||||
jest.mock('child_process', () => ({
|
||||
spawn: jest.fn(),
|
||||
}));
|
||||
@@ -26,6 +31,10 @@ jest.mock('../services/store.service', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../startup/login-shell-path', () => ({
|
||||
waitForLoginShellPath: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('../services/stalker-playback-context.service', () => ({
|
||||
getStalkerPlaybackContextHeaders: jest.fn(() => undefined),
|
||||
}));
|
||||
@@ -33,6 +42,7 @@ jest.mock('../services/stalker-playback-context.service', () => ({
|
||||
import { ipcMain } from 'electron';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as fs from 'fs';
|
||||
import {
|
||||
MPV_PLAYER_PATH,
|
||||
store,
|
||||
@@ -53,6 +63,7 @@ import {
|
||||
shouldUseMpvSocketBridge,
|
||||
} from './player.events';
|
||||
import { openVlcPlayer } from './vlc-session.service';
|
||||
import { waitForLoginShellPath } from '../startup/login-shell-path';
|
||||
|
||||
function createPathExists(existingPaths: string[]) {
|
||||
return (candidatePath: string) => existingPaths.includes(candidatePath);
|
||||
@@ -461,6 +472,117 @@ describe('openVlcPlayer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('external player launch handlers', () => {
|
||||
const pathKeys: Record<string, string> = {
|
||||
OPEN_MPV_PLAYER: MPV_PLAYER_PATH,
|
||||
OPEN_VLC_PLAYER: VLC_PLAYER_PATH,
|
||||
};
|
||||
|
||||
function configurePlayerPath(channel: string, playerPath: string): void {
|
||||
(store.get as unknown as jest.Mock).mockImplementation(
|
||||
(key: string, fallback?: unknown) =>
|
||||
key === pathKeys[channel] ? playerPath : fallback
|
||||
);
|
||||
}
|
||||
|
||||
async function launchUntilSpawn(
|
||||
channel: string,
|
||||
wait: Promise<void>,
|
||||
afterLaunch: () => Promise<void>
|
||||
): Promise<void> {
|
||||
(waitForLoginShellPath as jest.Mock).mockReturnValueOnce(wait);
|
||||
const proc = createMockChildProcess();
|
||||
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const launch = Promise.resolve(
|
||||
getIpcMainHandler(channel)(
|
||||
{},
|
||||
'https://example.com/live.m3u8',
|
||||
'Live'
|
||||
)
|
||||
).catch(() => undefined);
|
||||
await afterLaunch();
|
||||
proc.emit('error', new Error('spawn ENOENT'));
|
||||
await launch;
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(spawn as unknown as jest.Mock).mockReset();
|
||||
(waitForLoginShellPath as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
it.each(['OPEN_MPV_PLAYER', 'OPEN_VLC_PLAYER'])(
|
||||
'%s spawns a bare player name only after the login shell PATH lookup settled',
|
||||
async (channel) => {
|
||||
configurePlayerPath(channel, 'player-on-shell-path');
|
||||
let settle: () => void = () => undefined;
|
||||
const wait = new Promise<void>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
await launchUntilSpawn(channel, wait, async () => {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
settle();
|
||||
await waitForSpawnCallCount(1);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['OPEN_MPV_PLAYER', 'OPEN_VLC_PLAYER'])(
|
||||
'%s does not wait for a Flatpak host launch of a bare name',
|
||||
async (channel) => {
|
||||
const originalPlatform = process.platform;
|
||||
const existsSync = fs.existsSync as unknown as jest.Mock;
|
||||
existsSync.mockImplementation(
|
||||
(candidate: unknown) => String(candidate) === '/.flatpak-info'
|
||||
);
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
configurePlayerPath(channel, 'player-on-host-path');
|
||||
try {
|
||||
await launchUntilSpawn(
|
||||
channel,
|
||||
new Promise<void>(() => undefined),
|
||||
async () => {
|
||||
await waitForSpawnCallCount(1);
|
||||
expect(waitForLoginShellPath).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(spawn as unknown as jest.Mock).mock.calls[0][0]
|
||||
).toBe('flatpak-spawn');
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
existsSync.mockImplementation(
|
||||
jest.requireActual<typeof import('fs')>('fs').existsSync
|
||||
);
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['OPEN_MPV_PLAYER', 'OPEN_VLC_PLAYER'])(
|
||||
'%s starts a configured executable path without waiting',
|
||||
async (channel) => {
|
||||
configurePlayerPath(channel, '/opt/players/bin/player');
|
||||
await launchUntilSpawn(
|
||||
channel,
|
||||
new Promise<void>(() => undefined),
|
||||
async () => {
|
||||
await waitForSpawnCallCount(1);
|
||||
expect(waitForLoginShellPath).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('buildVlcEnqueueCommands', () => {
|
||||
it('clears the playlist and adds the URL with no extra options', () => {
|
||||
expect(
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
CLOSE_EXTERNAL_PLAYER_SESSION,
|
||||
type ExternalPlayerName,
|
||||
PLAYBACK_SET_KEEP_AWAKE,
|
||||
PlayerContentInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { setPlaybackKeepAwake } from '../services/playback-keep-awake.service';
|
||||
import { waitForLoginShellPath } from '../startup/login-shell-path';
|
||||
import {
|
||||
MPV_PLAYER_PATH,
|
||||
store,
|
||||
VLC_PLAYER_PATH,
|
||||
} from '../services/store.service';
|
||||
import { normalizePlayerPathForStore } from './external-player-launch-context';
|
||||
import {
|
||||
normalizePlayerPathForStore,
|
||||
resolveExternalPlayerLaunchContext as resolveLaunchContext,
|
||||
} from './external-player-launch-context';
|
||||
import {
|
||||
externalPlayerSessions,
|
||||
traceExternalPlayer,
|
||||
@@ -34,6 +39,23 @@ export {
|
||||
parseVlcRcPlaybackState,
|
||||
} from './vlc-session.service';
|
||||
|
||||
/**
|
||||
* A player resolved to a bare name (no configured path, no well-known
|
||||
* install found) is looked up through PATH, so it waits for the login shell
|
||||
* PATH; a path to an executable starts right away. So does a Flatpak host
|
||||
* launch: `flatpak-spawn --host` resolves the name with the host's PATH,
|
||||
* which the sandbox's login shell lookup cannot change.
|
||||
*/
|
||||
async function waitForPathIfBareName(
|
||||
player: ExternalPlayerName,
|
||||
configuredPath: string | undefined
|
||||
): Promise<void> {
|
||||
const context = resolveLaunchContext(player, configuredPath);
|
||||
if (context.mode !== 'flatpak-host' && !/[\\/]/.test(context.playerPath)) {
|
||||
await waitForLoginShellPath();
|
||||
}
|
||||
}
|
||||
|
||||
export default class PlayerEvents {
|
||||
static bootstrapPlayerEvents(): Electron.IpcMain {
|
||||
return ipcMain;
|
||||
@@ -53,8 +75,9 @@ ipcMain.handle(
|
||||
contentInfo?: PlayerContentInfo,
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
) =>
|
||||
openMpvPlayer({
|
||||
) => {
|
||||
await waitForPathIfBareName('mpv', store.get(MPV_PLAYER_PATH));
|
||||
return openMpvPlayer({
|
||||
url,
|
||||
title,
|
||||
thumbnail,
|
||||
@@ -64,7 +87,8 @@ ipcMain.handle(
|
||||
contentInfo,
|
||||
startTime,
|
||||
headers,
|
||||
})
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
@@ -95,8 +119,9 @@ ipcMain.handle(
|
||||
contentInfo?: PlayerContentInfo,
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
) =>
|
||||
openVlcPlayer({
|
||||
) => {
|
||||
await waitForPathIfBareName('vlc', store.get(VLC_PLAYER_PATH));
|
||||
return openVlcPlayer({
|
||||
url,
|
||||
title,
|
||||
thumbnail,
|
||||
@@ -106,7 +131,8 @@ ipcMain.handle(
|
||||
contentInfo,
|
||||
startTime,
|
||||
headers,
|
||||
})
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
|
||||
@@ -375,6 +375,60 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
expect(support.frameCopyAvailable).toBe(true);
|
||||
});
|
||||
|
||||
it('predicts the bare-name mpv probe exactly when getSupport runs it', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
mockSpawnSync.mockClear();
|
||||
mockSpawnSync.mockReturnValue({ status: 0 });
|
||||
mockRuntimeUsable();
|
||||
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
|
||||
service.getSupport();
|
||||
expect(mockSpawnSync).toHaveBeenCalledWith(
|
||||
'mpv',
|
||||
['--version'],
|
||||
expect.anything()
|
||||
);
|
||||
// Cached afterwards: no further probe, so no further wait.
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
|
||||
});
|
||||
|
||||
it('probes mpv again after a missing or a found result is forgotten', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
mockSpawnSync.mockClear();
|
||||
mockSpawnSync.mockReturnValue({ status: 1 });
|
||||
mockRuntimeUsable();
|
||||
|
||||
expect(service.getSupport().supported).toBe(false);
|
||||
service.forgetLinuxMpvExecutableProbe();
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
|
||||
|
||||
mockSpawnSync.mockReturnValue({ status: 0 });
|
||||
service.getSupport();
|
||||
expect(mockSpawnSync).toHaveBeenCalledTimes(2);
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
|
||||
service.forgetLinuxMpvExecutableProbe();
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
|
||||
});
|
||||
|
||||
it('predicts no probe for the frame-copy engine or native Wayland', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
||||
mockSpawnSync.mockClear();
|
||||
mockRuntimeUsable();
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
|
||||
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
|
||||
service.getSupport();
|
||||
expect(mockSpawnSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps frame-copy supported on Linux without a system mpv executable', () => {
|
||||
// The helper links libmpv itself; the mpv-on-PATH probe only
|
||||
// binds the native --wid engine.
|
||||
|
||||
@@ -377,6 +377,32 @@ export class EmbeddedMpvNativeService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `getSupport()` would now run the uncached Linux bare-name
|
||||
* `mpv --version` probe, i.e. reach the native-engine checks: no
|
||||
* frame-copy engine, a usable display server and the feature enabled.
|
||||
* The IPC layer waits for the login shell PATH only then.
|
||||
*/
|
||||
willProbeLinuxMpvExecutable(): boolean {
|
||||
return (
|
||||
process.platform === 'linux' &&
|
||||
this.cachedLinuxMpvExecutableReason === undefined &&
|
||||
!this.isUnsupportedLinuxDisplayServer() &&
|
||||
isEmbeddedMpvFeatureEnabled() &&
|
||||
!this.isFrameCopyEngineActive()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the cached `mpv --version` result, found or missing, so the next
|
||||
* support check probes again. For a probe that ran before the login
|
||||
* shell PATH arrived: that PATH may add mpv or leave out the directory
|
||||
* the inherited one found it in.
|
||||
*/
|
||||
forgetLinuxMpvExecutableProbe(): void {
|
||||
this.cachedLinuxMpvExecutableReason = undefined;
|
||||
}
|
||||
|
||||
getSupport(): EmbeddedMpvSupport {
|
||||
if (!SUPPORTED_EMBEDDED_MPV_PLATFORMS.has(process.platform)) {
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* main.ts loads this module through a dynamic import inside the main
|
||||
* window's `did-start-loading` listener (see deferred-bootstrap.ts), so the
|
||||
* heavy dependencies it pulls in (axios, drizzle-orm, better-sqlite3,
|
||||
* electron-updater, fix-path) are evaluated while the renderer parses and
|
||||
* electron-updater) are evaluated while the renderer parses and
|
||||
* runs its own bundle instead of before the window can load at all.
|
||||
*
|
||||
* Keep `bootstrapDeferredEvents()` synchronous: the guarantee that no
|
||||
@@ -51,6 +51,9 @@ import {
|
||||
} from '../services/app-update-channel';
|
||||
import { databaseWorkerClient } from '../services/database-worker-client';
|
||||
import type { bootstrapWindowCloseGuard } from '../services/window-close-guard.service';
|
||||
import { scheduleDeferredFixPath } from './login-shell-path';
|
||||
|
||||
export { scheduleDeferredFixPath };
|
||||
|
||||
export interface DeferredEventsContext {
|
||||
readonly appVersion: string;
|
||||
@@ -126,35 +129,6 @@ export async function finishStartupAfterFirstLoad(): Promise<void> {
|
||||
traceStartupPhase('reconcile-stale-recordings:done');
|
||||
}
|
||||
|
||||
let fixPathScheduled = false;
|
||||
|
||||
/**
|
||||
* Update process.env.PATH from the user's interactive login shell so that
|
||||
* spawned external players (MPV/VLC) can be resolved by binary name.
|
||||
*
|
||||
* Runs after window creation + IPC handler registration so the 50-300 ms
|
||||
* shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent:
|
||||
* subsequent calls are no-ops. fix-path itself is imported here, on demand,
|
||||
* so its module evaluation stays off the launch path as well.
|
||||
*/
|
||||
export function scheduleDeferredFixPath(): void {
|
||||
if (fixPathScheduled || process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
fixPathScheduled = true;
|
||||
setImmediate(() => {
|
||||
import('fix-path')
|
||||
.then(({ default: fixPath }) => {
|
||||
fixPath();
|
||||
traceStartupPhase('fix-path:done');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('fix-path failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Tears down sessions and the DB worker; safe when nothing was started. */
|
||||
export function shutdownDeferredServices(): void {
|
||||
shutdownEmbeddedMpv();
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
const shellPath = jest.fn<Promise<string | undefined>, []>();
|
||||
const shellPathSync = jest.fn<string | undefined, []>();
|
||||
|
||||
jest.mock('shell-path', () => ({ shellPath, shellPathSync }));
|
||||
jest.mock('../services/debug-trace', () => ({
|
||||
traceStartupPhase: jest.fn(),
|
||||
}));
|
||||
|
||||
type LoginShellPathModule = typeof import('./login-shell-path');
|
||||
|
||||
function loadModule(): LoginShellPathModule {
|
||||
let loaded: LoginShellPathModule | undefined;
|
||||
jest.isolateModules(() => {
|
||||
loaded = jest.requireActual('./login-shell-path');
|
||||
});
|
||||
return loaded as LoginShellPathModule;
|
||||
}
|
||||
|
||||
async function flushScheduled(): Promise<void> {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
describe('login shell PATH', () => {
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
shellPath.mockReset();
|
||||
shellPathSync.mockReset();
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.PATH = originalPath;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the login shell asynchronously, never through the blocking API', async () => {
|
||||
let resolveShell: (path: string) => void = () => undefined;
|
||||
shellPath.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveShell = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
loadModule().scheduleDeferredFixPath();
|
||||
await flushScheduled();
|
||||
|
||||
// The shell is still starting, and the main thread is free.
|
||||
expect(shellPath).toHaveBeenCalledTimes(1);
|
||||
expect(shellPathSync).not.toHaveBeenCalled();
|
||||
expect(process.env.PATH).toBe('/usr/bin:/bin');
|
||||
|
||||
resolveShell('/opt/homebrew/bin:/usr/bin:/bin');
|
||||
await flushScheduled();
|
||||
expect(process.env.PATH).toBe('/opt/homebrew/bin:/usr/bin:/bin');
|
||||
});
|
||||
|
||||
it('lets spawns wait until the lookup settled, at most the limit', async () => {
|
||||
let resolveShell: (path: string) => void = () => undefined;
|
||||
const module = loadModule();
|
||||
module.scheduleDeferredFixPath(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveShell = resolve;
|
||||
})
|
||||
);
|
||||
let waited = false;
|
||||
const wait = module.waitForLoginShellPath().then(() => {
|
||||
waited = true;
|
||||
});
|
||||
await flushScheduled();
|
||||
expect(waited).toBe(false);
|
||||
|
||||
resolveShell('/opt/homebrew/bin');
|
||||
await wait;
|
||||
expect(process.env.PATH).toBe('/opt/homebrew/bin');
|
||||
});
|
||||
|
||||
it('ends every wait at the lookup budget, so a hung shell delays no later launch', async () => {
|
||||
const stuck = loadModule();
|
||||
stuck.scheduleDeferredFixPath(() => new Promise(() => undefined), 20);
|
||||
await expect(stuck.waitForLoginShellPath()).resolves.toBe(false);
|
||||
// Past the budget a later launch goes at once (the test would time
|
||||
// out on this one otherwise).
|
||||
await expect(stuck.waitForLoginShellPath(60_000)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('lets waits before the lookup is scheduled share one deadline', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const module = loadModule();
|
||||
const first = module.waitForLoginShellPath();
|
||||
await jest.advanceTimersByTimeAsync(6_000);
|
||||
const retry = module.waitForLoginShellPath();
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
// Both end at the first wait's deadline, not 10 s after the retry.
|
||||
await expect(first).resolves.toBe(false);
|
||||
await expect(retry).resolves.toBe(false);
|
||||
await expect(module.waitForLoginShellPath()).resolves.toBe(false);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a launch retried within the budget wait for the PATH again', async () => {
|
||||
let resolveShell: (path: string) => void = () => undefined;
|
||||
const module = loadModule();
|
||||
module.scheduleDeferredFixPath(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveShell = resolve;
|
||||
}),
|
||||
60_000
|
||||
);
|
||||
await flushScheduled();
|
||||
// The first launch gives up on its own, shorter limit...
|
||||
await module.waitForLoginShellPath(5);
|
||||
expect(process.env.PATH).toBe('/usr/bin:/bin');
|
||||
|
||||
// ...a retry still waits, and sees the PATH once the shell answers.
|
||||
const retry = module.waitForLoginShellPath();
|
||||
resolveShell('/opt/homebrew/bin');
|
||||
await expect(retry).resolves.toBe(true);
|
||||
expect(process.env.PATH).toBe('/opt/homebrew/bin');
|
||||
});
|
||||
|
||||
it('lets spawns go immediately on Windows', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
await expect(loadModule().waitForLoginShellPath(60_000)).resolves.toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the paths fix-path used when the shell reports none', async () => {
|
||||
await loadModule().hydratePathFromLoginShell(async () => undefined);
|
||||
|
||||
expect(process.env.PATH).toBe(
|
||||
'./node_modules/.bin:/.nodebrew/current/bin:/usr/local/bin:/usr/bin:/bin'
|
||||
);
|
||||
});
|
||||
|
||||
it('runs once, and not at all on Windows', async () => {
|
||||
const read = jest.fn(async () => '/custom/bin');
|
||||
const module = loadModule();
|
||||
module.scheduleDeferredFixPath(read);
|
||||
module.scheduleDeferredFixPath(read);
|
||||
await flushScheduled();
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
const windowsRead = jest.fn(async () => '/custom/bin');
|
||||
loadModule().scheduleDeferredFixPath(windowsRead);
|
||||
await flushScheduled();
|
||||
expect(windowsRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the current PATH when the lookup fails', async () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {
|
||||
// Expected failure.
|
||||
});
|
||||
loadModule().scheduleDeferredFixPath(async () => {
|
||||
throw new Error('shell exited');
|
||||
});
|
||||
await flushScheduled();
|
||||
|
||||
expect(process.env.PATH).toBe('/usr/bin:/bin');
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Login shell PATH lookup failed:',
|
||||
expect.any(Error)
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { traceStartupPhase } from '../services/debug-trace';
|
||||
|
||||
/**
|
||||
* Update process.env.PATH from the user's interactive login shell so that
|
||||
* spawned external players (MPV/VLC) can be resolved by binary name when the
|
||||
* app was started from Finder or a desktop launcher.
|
||||
*
|
||||
* The shell (`$SHELL -ilc env`) starts asynchronously. Its start-up cost
|
||||
* depends on the user's shell profile and was about 1-2 s with a typical
|
||||
* zsh setup; the synchronous `fix-path` used before blocked the main thread
|
||||
* for all of it, right when the database worker's `ready` message and the
|
||||
* renderer's first IPC calls were waiting, which delayed the first card of
|
||||
* the launch journey by the same amount. The resulting PATH, and the
|
||||
* fallback when the shell reports none, are the ones `fix-path` 5 produced.
|
||||
*
|
||||
* Runs after window creation and IPC handler registration. Idempotent:
|
||||
* subsequent calls are no-ops. shell-path is imported here, on demand, so
|
||||
* its module evaluation stays off the launch path as well.
|
||||
*
|
||||
* Handlers that spawn a binary by bare name (external players, the Linux
|
||||
* `mpv --version` check of embedded MPV) await `waitForLoginShellPath()`
|
||||
* first, so they see the same PATH the blocking lookup guaranteed.
|
||||
*/
|
||||
let loginShellPathScheduled = false;
|
||||
let settleLoginShellPath: () => void = () => undefined;
|
||||
const loginShellPathSettled: Promise<void> =
|
||||
process.platform === 'win32'
|
||||
? Promise.resolve()
|
||||
: new Promise((resolve) => {
|
||||
settleLoginShellPath = resolve;
|
||||
});
|
||||
|
||||
/**
|
||||
* Time budget of the lookup, counted from its start. A shell that has not
|
||||
* answered by then is treated as hung: waits end at this shared deadline,
|
||||
* so a broken profile cannot delay every launch by the full budget, while a
|
||||
* launch retried within the budget still waits for the PATH.
|
||||
*/
|
||||
export const LOGIN_SHELL_PATH_WAIT_LIMIT_MS = 10_000;
|
||||
let loginShellPathDeadline: number | null = null;
|
||||
|
||||
let loginShellPathHasSettled = process.platform === 'win32';
|
||||
const settledOutcome = loginShellPathSettled.then(() => {
|
||||
loginShellPathHasSettled = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolves once the login shell PATH lookup has finished (successfully or
|
||||
* not), or when its budget is spent; immediately on Windows. Before the
|
||||
* lookup is scheduled, waits end at a deadline shared from the first of
|
||||
* them. `limitMs` caps a single wait. Resolves to whether
|
||||
* the lookup had finished: false means the caller runs on the inherited
|
||||
* PATH and should not keep a negative result.
|
||||
*/
|
||||
export function waitForLoginShellPath(
|
||||
limitMs = LOGIN_SHELL_PATH_WAIT_LIMIT_MS
|
||||
): Promise<boolean> {
|
||||
if (loginShellPathHasSettled) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
// Waits before the lookup is scheduled share one deadline too, set by
|
||||
// the first of them; scheduling the lookup replaces it with its budget.
|
||||
loginShellPathDeadline ??= Date.now() + LOGIN_SHELL_PATH_WAIT_LIMIT_MS;
|
||||
const remainingMs = Math.min(limitMs, loginShellPathDeadline - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const limit = new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), remainingMs);
|
||||
});
|
||||
return Promise.race([settledOutcome, limit]).finally(() =>
|
||||
clearTimeout(timer)
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves when the lookup finishes, however long it takes. */
|
||||
export function whenLoginShellPathSettled(): Promise<void> {
|
||||
return loginShellPathSettled;
|
||||
}
|
||||
|
||||
export type ReadLoginShellPath = () => Promise<string | undefined>;
|
||||
|
||||
const readLoginShellPath: ReadLoginShellPath = async () => {
|
||||
const { shellPath } = await import('shell-path');
|
||||
return shellPath();
|
||||
};
|
||||
|
||||
export async function hydratePathFromLoginShell(
|
||||
readPath: ReadLoginShellPath = readLoginShellPath
|
||||
): Promise<void> {
|
||||
const shellPath = await readPath();
|
||||
process.env.PATH =
|
||||
shellPath ||
|
||||
[
|
||||
'./node_modules/.bin',
|
||||
'/.nodebrew/current/bin',
|
||||
'/usr/local/bin',
|
||||
process.env.PATH,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
export function scheduleDeferredFixPath(
|
||||
readPath: ReadLoginShellPath = readLoginShellPath,
|
||||
budgetMs = LOGIN_SHELL_PATH_WAIT_LIMIT_MS
|
||||
): void {
|
||||
if (loginShellPathScheduled || process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
loginShellPathScheduled = true;
|
||||
loginShellPathDeadline = Date.now() + budgetMs;
|
||||
setImmediate(() => {
|
||||
hydratePathFromLoginShell(readPath)
|
||||
.then(() => {
|
||||
traceStartupPhase('fix-path:done');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Login shell PATH lookup failed:', error);
|
||||
})
|
||||
.finally(() => settleLoginShellPath());
|
||||
});
|
||||
}
|
||||
@@ -184,12 +184,13 @@ export default class Main {
|
||||
traceStartupPhase('bootstrap-events:done');
|
||||
|
||||
// Hydrate process.env.PATH from the user's login shell now — after
|
||||
// the window has loaded and IPC handlers are live. Fire-and-forget
|
||||
// (setImmediate) so it doesn't gate any user-visible work. Worst
|
||||
// case: the user clicks an external player within the ~100 ms it
|
||||
// takes to complete; the spawn would still find MPV/VLC at any of
|
||||
// the well-known paths checked by getDefault*Path before falling
|
||||
// back to bare-name PATH lookup.
|
||||
// the window has loaded and IPC handlers are live. The shell runs
|
||||
// asynchronously, so the main thread keeps serving the renderer
|
||||
// while it starts (about 1-2 s with a typical zsh profile). Worst
|
||||
// case: the user clicks an external player before it completes;
|
||||
// the spawn would still find MPV/VLC at any of the well-known paths
|
||||
// checked by getDefault*Path before falling back to bare-name PATH
|
||||
// lookup.
|
||||
module.scheduleDeferredFixPath();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -36,7 +36,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/);
|
||||
}
|
||||
@@ -73,7 +75,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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -245,7 +245,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();
|
||||
@@ -289,7 +292,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();
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
|
||||
@@ -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": "فرز القوائم",
|
||||
|
||||
@@ -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": "رتب قوائم التشغيل",
|
||||
|
||||
@@ -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": "Сартаваць плэйлісты",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ταξινόμηση λιστών αναπαραγωγής",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "プレイリストを並び替え",
|
||||
|
||||
@@ -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": "재생목록 정렬",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сортировать плейлисты",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "排序播放列表",
|
||||
|
||||
@@ -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": "排序播放清單",
|
||||
|
||||
@@ -104,6 +104,9 @@ html {
|
||||
var(--app-selection-color) 34%,
|
||||
transparent
|
||||
);
|
||||
// Watch progress in app chrome (rail cards, catalog capsules, the hero).
|
||||
// Over video the player's fixed `--pc-progress` applies instead.
|
||||
--app-progress-color: var(--app-selection-color);
|
||||
|
||||
// Live / on-air accent (broadcast indicator)
|
||||
--app-live-color: #d12c2c;
|
||||
@@ -200,6 +203,9 @@ html {
|
||||
var(--app-selection-color) 44%,
|
||||
transparent
|
||||
);
|
||||
// Re-declared so it resolves against this context's selection colour
|
||||
// rather than inheriting the light value computed on `html`.
|
||||
--app-progress-color: var(--app-selection-color);
|
||||
|
||||
// Live / on-air accent (broadcast indicator)
|
||||
--app-live-color: #ff8f8f;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// A custom property that reads another one is resolved where it is declared,
|
||||
// so a token derived from a per-theme colour has to be declared again inside
|
||||
// `.dark-theme`; inherited from `html`, it would keep the light value there.
|
||||
const THEME = readFileSync(
|
||||
resolve(process.cwd(), 'apps/web/src/m3-theme.scss'),
|
||||
'utf8'
|
||||
)
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|\s)\/\/[^\n]*/g, '$1');
|
||||
|
||||
/** The body of the brace block that opens at `start` (an index of `{`). */
|
||||
function block(source: string, start: number): string {
|
||||
let depth = 0;
|
||||
for (let index = start; index < source.length; index++) {
|
||||
if (source[index] === '{') depth++;
|
||||
if (source[index] === '}' && --depth === 0) {
|
||||
return source.slice(start + 1, index);
|
||||
}
|
||||
}
|
||||
throw new Error('Unbalanced braces in m3-theme.scss');
|
||||
}
|
||||
|
||||
/** Light declarations (the `html` block minus `.dark-theme`) and dark ones. */
|
||||
function themeContexts(): { light: string; dark: string } {
|
||||
const darkStart = THEME.indexOf('.dark-theme {');
|
||||
const htmlStart = THEME.lastIndexOf('html {', darkStart);
|
||||
expect(darkStart).toBeGreaterThan(-1);
|
||||
expect(htmlStart).toBeGreaterThan(-1);
|
||||
|
||||
const html = block(THEME, THEME.indexOf('{', htmlStart));
|
||||
const dark = block(THEME, THEME.indexOf('{', darkStart));
|
||||
return { light: html.replace(dark, ''), dark };
|
||||
}
|
||||
|
||||
describe('m3-theme watch-progress token', () => {
|
||||
it('declares --app-progress-color from the selection colour in both themes', () => {
|
||||
const { light, dark } = themeContexts();
|
||||
const progress =
|
||||
/--app-progress-color:\s*var\(--app-selection-color\);/;
|
||||
const selection = /--app-selection-color:\s*#[0-9a-f]{6};/i;
|
||||
|
||||
for (const context of [light, dark]) {
|
||||
expect(context).toMatch(selection);
|
||||
expect(context).toMatch(progress);
|
||||
}
|
||||
expect(light.match(selection)?.[0]).not.toBe(
|
||||
dark.match(selection)?.[0]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -211,7 +211,8 @@ becomes an "Up Next" episode rail (`app-up-next-rail`,
|
||||
`libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.ts`).
|
||||
The rail lists the currently playing episode (highlighted, click-inert)
|
||||
followed by the rest of its season and a spillover into the following
|
||||
seasons, with per-episode watch-progress bars from playback positions.
|
||||
seasons, with per-episode watch-progress bars from playback positions,
|
||||
drawn in the player's `--pc-progress` like the dock's Up next card.
|
||||
|
||||
- Data flow: the hosts (Xtream `SerialDetailsComponent`, Stalker
|
||||
`StalkerSeriesViewComponent`) build the entries with
|
||||
|
||||
@@ -552,6 +552,39 @@ a cache read open and checks text contrast across live theme changes.
|
||||
|
||||
Channel preview progress and EPG current-program progress should stay visually aligned.
|
||||
|
||||
### Watch progress colour
|
||||
|
||||
A title's watch progress (its resume share, `progressPercent`) has exactly one
|
||||
colour per context, and never a literal of its own:
|
||||
|
||||
- **App chrome** — dashboard rail cards and the hero, catalog grids and season
|
||||
episodes (`app-progress-capsule`, and the season list rows' own fill):
|
||||
`--app-progress-color`, declared per theme in `apps/web/src/m3-theme.scss`
|
||||
as that theme's `--app-selection-color`, and declared again inside
|
||||
`.dark-theme` because a derived custom property resolves where it is
|
||||
declared. The capsule's green from 90 % marks a finished title; it is a
|
||||
status, not progress.
|
||||
- **Over video** — the dock timeline, the Up next card, the Up Next rail and
|
||||
the fullscreen episode panel: the player's fixed `--pc-progress` (accent
|
||||
blue `#4f8eff`), never an app token, because the player palette is
|
||||
theme-independent (see Player And EPG Theme Boundaries). An episode
|
||||
therefore reads the same in every player surface. The rail and the episode
|
||||
panel render beside the controls host, outside its `--pc-*` scope, so they
|
||||
declare the token on their own `:host` with the `progress-token` mixin of
|
||||
`libs/ui/playback/src/lib/player-controls/_player-palette.scss`.
|
||||
- ArtPlayer's legacy skin takes a colour string, not a custom property, so it
|
||||
gets `PLAYER_PROGRESS_COLOR` (`player-palette.ts`), which a spec pins to the
|
||||
Sass value.
|
||||
- Live programme progress next to a LIVE marker (the dashboard's live rail and
|
||||
live hero slides) keeps `--app-live-color`; EPG programme progress keeps the
|
||||
fill described below.
|
||||
|
||||
Specs hold the rule in each owning project: `m3-theme.spec.ts` (web: the token
|
||||
in both theme contexts), `player-progress.palette.spec.ts` (ui-playback),
|
||||
`progress-capsule.component.spec.ts` and
|
||||
`season-container.progress-colour.spec.ts` (components) and
|
||||
`dashboard-progress-colour.spec.ts` (workspace-dashboard-feature).
|
||||
|
||||
### Track
|
||||
|
||||
- Height:
|
||||
@@ -738,6 +771,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
|
||||
|
||||
@@ -421,6 +421,17 @@ process before the first card.
|
||||
completion bumps `EpgSourceSettingsService.revision()`, the fence that
|
||||
keeps XMLTV lookups from returning data of a removed source.
|
||||
|
||||
- The main thread must stay free between the load and the first card. The
|
||||
renderer's first database read creates the database worker, and every
|
||||
request waits until the main process has handled the worker's `ready`
|
||||
message. Until #1784 the login-shell PATH lookup (`fix-path`) ran right
|
||||
after `bootstrap-events:done` and spawned `$SHELL -ilc env` synchronously:
|
||||
on a Mac with a typical zsh profile it held the main thread for about
|
||||
1-2 s, the first `dbGetAppState` resolved about 2.3 s after spawn, and
|
||||
everything after it (about 20 ms of IPC) waited. It now runs the shell
|
||||
asynchronously (`startup/login-shell-path.ts`). On Linux runners bash
|
||||
starts in tens of milliseconds, so the effect there is small.
|
||||
|
||||
Validation (#1716, Principle 3): deferring the download list, update status
|
||||
and dashboard recent/favorites reads until after the first render took the
|
||||
counter from 12 to 7 on a Mac, but moved neither `spawnToFirstCardMs` nor
|
||||
|
||||
@@ -317,6 +317,10 @@ record glyph and the recording status (6.2:1 on the glass over a black
|
||||
frame). They are literal on purpose: the overlay is
|
||||
theme-independent (see the UI guidelines' player theme boundary), and the
|
||||
app's `--app-selection-color` is a different blue that would fight the video.
|
||||
Watch progress reads `--pc-progress`, the accent blue declared by the
|
||||
`progress-token` mixin of `_player-palette.scss`; the Up Next rail and the
|
||||
fullscreen episode panel include the same mixin, so one episode's bar has
|
||||
one colour in every player surface (UI guidelines, "Watch progress colour").
|
||||
|
||||
- **Timeline row**: current time (`--pc-font-mono`, tabular) · drawn track
|
||||
(`.player-controls__timeline-track` with one segment and an accent fill,
|
||||
@@ -406,7 +410,7 @@ the native-view legacy dock keeps its plain slider.
|
||||
Near the end of a series episode the dock shows an **"Up next" card**
|
||||
(`app-player-up-next-card`, `data-test-id="player-controls-up-next"`) in the
|
||||
bottom-right corner above the controls: the next episode's still (or its
|
||||
`S01E03` label as a tile), a 3px accent progress line when it was partly
|
||||
`S01E03` label as a tile), a 3px `--pc-progress` line when it was partly
|
||||
watched, "Up next · in 7 min" and the title. The host supplies the item
|
||||
through the optional `upNext` input (`PlayerUpNextItem { label, title,
|
||||
thumbnailUrl, progressPercent }`); `ControlsUpNext` decides when it shows —
|
||||
@@ -879,7 +883,7 @@ be.
|
||||
rows: a 16:9 still or, without one, a large numeral tile so the no-TMDB
|
||||
case still looks designed; the `S01E03` label, runtime, watched check or
|
||||
"Now playing" marker; the title (label as fallback); a 3-line clamped
|
||||
overview when there is one; a progress bar on the thumbnail. Rows are
|
||||
overview when there is one; a `--pc-progress` bar on the thumbnail. Rows are
|
||||
buttons inside `<li>`s of a `<ul>`, so their button role survives for
|
||||
assistive technology. A loading season shows a spinner row, a loaded empty
|
||||
one the season-empty copy, and an unanswered one a "could not be loaded"
|
||||
@@ -1652,6 +1656,8 @@ libs/ui/playback/src/lib/player-controls/
|
||||
├── player-controls.component.ts
|
||||
├── player-controls.component.html
|
||||
├── player-controls.component.scss
|
||||
├── _player-palette.scss
|
||||
├── player-palette.ts
|
||||
├── controls-feedback.ts
|
||||
├── controls-format.utils.ts
|
||||
├── controls-layout.ts
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -242,14 +242,32 @@ pnpm run coverage:e2e:v8:web
|
||||
## I18n
|
||||
|
||||
```bash
|
||||
pnpm run i18n:check
|
||||
pnpm run i18n:validate # checker unit tests, then the check (CI)
|
||||
pnpm run i18n:check # the check only
|
||||
pnpm run i18n:baseline:update # deliberate: rewrite the English-identical baseline
|
||||
```
|
||||
|
||||
The i18n check is non-mutating. It compares every locale file in
|
||||
`apps/web/src/assets/i18n/` against `en.json` and fails on missing or extra keys.
|
||||
Identical English fallback values are reported as warnings by default; use
|
||||
`node tools/i18n/check-drift.mjs --fail-on-identical` for a stricter translation
|
||||
audit.
|
||||
It also fails on a locale value that is identical to English unless
|
||||
`tools/i18n/identical-en-baseline.json` records that exact English text for the
|
||||
same locale and key. The baseline holds values that are legitimately the same
|
||||
in a language (brand and technical names, language autonyms, loanwords such as
|
||||
"PIN") and the untranslated debt that existed when the guard was added, so new
|
||||
keys must ship translated. An entry stops covering its key once the English
|
||||
text changes, so copying reworded English into a locale fails as well.
|
||||
|
||||
Baseline entries that are no longer English-identical (translated, removed, or
|
||||
reworded) are reported but do not fail. `pnpm run i18n:baseline:update`
|
||||
rewrites the baseline from the current locale files, dropping those entries
|
||||
and printing every added one; it writes nothing while any locale is unreadable
|
||||
or has missing or extra keys. Run it only after translating, or for a value
|
||||
that is legitimately identical in that language; review the diff, and never
|
||||
run it in CI. A new locale starts with no baseline entries, so it has to
|
||||
record its legitimate identical values the same way.
|
||||
`node tools/i18n/check-drift.mjs --fail-on-identical` ignores the baseline for
|
||||
a full translation audit. CI runs `pnpm run i18n:validate` in the unit test
|
||||
job.
|
||||
|
||||
## Performance
|
||||
|
||||
|
||||
+7
-7
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
+68
-2
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
+33
-1
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
+22
-2
@@ -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>
|
||||
|
||||
+220
-14
@@ -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' });
|
||||
});
|
||||
});
|
||||
+56
-38
@@ -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();
|
||||
});
|
||||
});
|
||||
+18
-4
@@ -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>
|
||||
+115
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
+12
@@ -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: '',
|
||||
|
||||
+110
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
+14
-1
@@ -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) {
|
||||
|
||||
+2
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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';
|
||||
|
||||
+90
@@ -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();
|
||||
});
|
||||
});
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { ProgressCapsuleComponent } from './progress-capsule.component';
|
||||
|
||||
describe('ProgressCapsuleComponent', () => {
|
||||
let fixture: ComponentFixture<ProgressCapsuleComponent>;
|
||||
|
||||
function render(progress: number): HTMLElement {
|
||||
fixture = TestBed.createComponent(ProgressCapsuleComponent);
|
||||
fixture.componentRef.setInput('progress', progress);
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
}
|
||||
|
||||
it('fills the capsule to the watched share', () => {
|
||||
const host = render(42);
|
||||
|
||||
expect(
|
||||
host.querySelector<HTMLElement>('.progress-capsule__fill')?.style
|
||||
.width
|
||||
).toBe('42%');
|
||||
expect(host.querySelector('.progress-capsule--watched')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks an item watched from 90 %', () => {
|
||||
expect(
|
||||
render(90).querySelector('.progress-capsule--watched')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
// The capsule sits in app chrome (catalog grids, season episodes), so its
|
||||
// fill is the theme's watch-progress token, like the dashboard rail card.
|
||||
it('draws progress with the app watch-progress token', () => {
|
||||
const source = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'libs/ui/components/src/lib/progress-capsule/progress-capsule.component.ts'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
const fill = source.match(/&__fill\s*\{([^}]*)\}/)?.[1] ?? '';
|
||||
|
||||
expect(fill).toMatch(/background:\s*var\(--app-progress-color\);/);
|
||||
expect(source).not.toMatch(/#e50914|#ff4d4d/i);
|
||||
});
|
||||
});
|
||||
@@ -34,11 +34,7 @@ import {
|
||||
|
||||
&__fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#e50914 0%,
|
||||
#ff4d4d 100%
|
||||
);
|
||||
background: var(--app-progress-color);
|
||||
transition: width 0.3s ease-out;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
@@ -770,7 +770,7 @@
|
||||
|
||||
&__progress-fill {
|
||||
height: 100%;
|
||||
background: var(--mat-sys-primary);
|
||||
background: var(--app-progress-color);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Watch progress in app chrome reads one theme token in both season view
|
||||
// modes; the list rows draw their own fill rather than the grid's capsule
|
||||
// (see the UI guidelines' Progress Bars section).
|
||||
const DIR = resolve(
|
||||
process.cwd(),
|
||||
'libs/ui/components/src/lib/season-container'
|
||||
);
|
||||
|
||||
function read(file: string): string {
|
||||
return readFileSync(resolve(DIR, file), 'utf8');
|
||||
}
|
||||
|
||||
describe('season container watch-progress colour', () => {
|
||||
it('fills list rows with the app watch-progress token', () => {
|
||||
const styles = read('season-container.component.scss');
|
||||
const listItem = styles.slice(styles.indexOf('.episode-list-item {'));
|
||||
const fill = listItem.match(/&__progress-fill\s*\{([^}]*)\}/)?.[1];
|
||||
|
||||
expect(read('season-container.component.html')).toContain(
|
||||
'class="episode-list-item__progress-fill"'
|
||||
);
|
||||
expect(fill).toMatch(/background:\s*var\(--app-progress-color\);/);
|
||||
});
|
||||
});
|
||||
@@ -287,6 +287,8 @@ describe('ArtPlayerComponent', () => {
|
||||
setting: true,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
// The skin's progress bar matches the shared dock's fill.
|
||||
theme: '#4f8eff',
|
||||
})
|
||||
);
|
||||
// The app-level legacy shortcuts own the keyboard instead of
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import Artplayer from 'artplayer';
|
||||
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
|
||||
import { releaseVideoPictureInPicture } from '../player-controls/web-video-picture-in-picture-lifecycle';
|
||||
import { PLAYER_PROGRESS_COLOR } from '../player-controls/player-palette';
|
||||
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
|
||||
import {
|
||||
type LegacyPlayerShortcuts,
|
||||
@@ -179,7 +180,9 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
playsInline: true,
|
||||
backdrop: true,
|
||||
mutex: true,
|
||||
theme: '#ff0000',
|
||||
// Colours the vendor chrome (legacy skin only; shared controls
|
||||
// hide it) with the player's progress colour, not a stray red.
|
||||
theme: PLAYER_PROGRESS_COLOR,
|
||||
...buildArtPlayerChrome(this.sharedControls),
|
||||
customType: sourceSession.customType,
|
||||
});
|
||||
|
||||
+6
-1
@@ -1,7 +1,12 @@
|
||||
@use '../player-controls/player-palette' as palette;
|
||||
|
||||
// Stamped into the fullscreen panel's body, which is its single flex child;
|
||||
// the list below is the body's only scroll owner. The panel wears the
|
||||
// `dark-theme` context class, so the light-on-dark palette here is fixed.
|
||||
// The panel sits beside the controls host, outside its `--pc-*` scope, so it
|
||||
// declares the player's progress colour itself.
|
||||
:host {
|
||||
@include palette.progress-token;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
@@ -238,7 +243,7 @@
|
||||
.episode-panel__progress-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #e50914;
|
||||
background: var(--pc-progress);
|
||||
}
|
||||
|
||||
// ─── Text ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Player colours that surfaces outside the controls host need too. The
|
||||
// controls declare their `--pc-*` palette on `app-player-controls`' `:host`;
|
||||
// the Up Next rail and the fullscreen episode panel render beside that host,
|
||||
// not inside it, so they include `progress-token` on their own `:host` to
|
||||
// draw watch progress in the same colour as the dock.
|
||||
//
|
||||
// ArtPlayer's vendor chrome takes the same value as a string at construction:
|
||||
// `PLAYER_PROGRESS_COLOR` in `player-palette.ts`, pinned to `$accent-blue` by
|
||||
// `player-progress.palette.spec.ts`.
|
||||
|
||||
// Accent blue: the primary action and watch progress over video.
|
||||
$accent-blue: #4f8eff;
|
||||
|
||||
// `--pc-progress`, the watch-progress fill over video. Literal like the rest
|
||||
// of the overlay palette: the player is theme-independent, and the app's
|
||||
// selection blue would change with the theme and fight the video.
|
||||
@mixin progress-token {
|
||||
--pc-progress: #{$accent-blue};
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
@use 'player-palette' as palette;
|
||||
|
||||
:host {
|
||||
// Overlay palette. Deliberately literal and theme-independent: the shared
|
||||
// controls always sit on video, and the UI guidelines keep them on one
|
||||
// light-on-dark palette in both app themes. The values are the design
|
||||
// tokens of the "Hybrid" controls concept (accent blue = primary action
|
||||
// and progress; cyan = something is on; violet = a value was changed).
|
||||
--pc-accent-blue: #4f8eff;
|
||||
// Accent blue and the progress fill come from the shared player palette,
|
||||
// which the Up Next rail and the fullscreen episode panel also read.
|
||||
--pc-accent-blue: #{palette.$accent-blue};
|
||||
@include palette.progress-token;
|
||||
// Fills that carry a white glyph (play, active tune) use deeper blues:
|
||||
// white on #4f8eff is only 3.2:1, too close to the 3:1 floor once a
|
||||
// 1x display antialiases the glyph. These give 4.4:1 and 5.3:1.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* The player's watch-progress colour (`--pc-progress`) for code that needs a
|
||||
* colour string rather than a CSS custom property, such as ArtPlayer's
|
||||
* `theme` option. It must equal `$accent-blue` in `_player-palette.scss`;
|
||||
* `player-progress.palette.spec.ts` keeps the two in step.
|
||||
*/
|
||||
export const PLAYER_PROGRESS_COLOR = '#4f8eff';
|
||||
@@ -0,0 +1,156 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { relative, resolve, sep } from 'node:path';
|
||||
import { PLAYER_PROGRESS_COLOR } from './player-palette';
|
||||
|
||||
// Watch progress inside the player is one colour: the fixed `--pc-progress`,
|
||||
// never the app theme and never a literal of its own. The same episode must
|
||||
// read the same in the dock's Up next card, the Up Next rail and the
|
||||
// fullscreen episode panel (see the UI guidelines' Progress Bars section).
|
||||
const LIB_DIR = resolve(process.cwd(), 'libs/ui/playback/src/lib');
|
||||
const CONTROLS_DIR = resolve(LIB_DIR, 'player-controls');
|
||||
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|\s)\/\/[^\n]*/g, '$1');
|
||||
}
|
||||
|
||||
function listSources(dir: string, extension: string): string[] {
|
||||
return readdirSync(dir, { recursive: true, encoding: 'utf8' })
|
||||
.filter((file) => file.endsWith(extension))
|
||||
.filter((file) => !/\.spec(-[a-z]+)?\.ts$/.test(file))
|
||||
.map((file) => resolve(dir, file));
|
||||
}
|
||||
|
||||
function read(file: string): string {
|
||||
return stripComments(readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
/** The declarations of the first rule whose selector list is exactly `selector`. */
|
||||
function ruleBody(source: string, selector: string): string {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return (
|
||||
source.match(
|
||||
new RegExp(`(?:^|[}\\s])${escaped}\\s*\\{([^}]*)\\}`)
|
||||
)?.[1] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
const PALETTE = read(resolve(CONTROLS_DIR, '_player-palette.scss'));
|
||||
const STYLESHEETS = listSources(LIB_DIR, '.scss');
|
||||
|
||||
const PROGRESS_FILLS: ReadonlyArray<[file: string, selector: string]> = [
|
||||
[
|
||||
'player-controls/player-timeline.component.scss',
|
||||
'.player-controls__timeline-fill',
|
||||
],
|
||||
[
|
||||
'player-controls/player-up-next-card.component.scss',
|
||||
'.player-up-next__progress-bar',
|
||||
],
|
||||
[
|
||||
'portal-inline-player/up-next-rail.component.scss',
|
||||
'.up-next__progress-bar',
|
||||
],
|
||||
[
|
||||
'fullscreen-episode-panel/fullscreen-episode-panel.component.scss',
|
||||
'.episode-panel__progress-bar',
|
||||
],
|
||||
];
|
||||
|
||||
describe('player watch-progress colour', () => {
|
||||
it('draws every progress fill inside the player with --pc-progress', () => {
|
||||
for (const [file, selector] of PROGRESS_FILLS) {
|
||||
expect({
|
||||
file,
|
||||
body: ruleBody(read(resolve(LIB_DIR, file)), selector),
|
||||
}).toEqual({
|
||||
file,
|
||||
body: expect.stringMatching(
|
||||
/background:\s*var\(--pc-progress\);/
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves no other BEM progress fill on a colour of its own', () => {
|
||||
const fills = STYLESHEETS.flatMap((file) =>
|
||||
[
|
||||
...read(file).matchAll(
|
||||
/([^{}]*__progress-(?:bar|fill))\s*\{([^}]*)\}/g
|
||||
),
|
||||
].map(([, selector, body]) => ({
|
||||
fill: `${relative(LIB_DIR, file)} ${selector.trim()}`,
|
||||
background: body.match(
|
||||
/background(?:-color)?:\s*([^;]+);/
|
||||
)?.[1],
|
||||
}))
|
||||
);
|
||||
|
||||
expect(fills.length).toBeGreaterThanOrEqual(3);
|
||||
expect(
|
||||
fills.filter(
|
||||
({ background }) => background !== 'var(--pc-progress)'
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('declares --pc-progress wherever it is read outside the controls host', () => {
|
||||
// A top-level `:host` block runs to the first unindented `}`; Sass
|
||||
// interpolation (`#{...}`) inside it would end `ruleBody` too early.
|
||||
const declaresOnHost = (source: string) =>
|
||||
/@include palette\.progress-token;/.test(
|
||||
source.match(/^:host\s*\{([\s\S]*?)^\}/m)?.[1] ?? ''
|
||||
);
|
||||
|
||||
// The host declares the palette its own children (timeline, card) read.
|
||||
expect(
|
||||
declaresOnHost(
|
||||
read(resolve(CONTROLS_DIR, 'player-controls.component.scss'))
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const readers = STYLESHEETS.filter(
|
||||
(file) =>
|
||||
!file.startsWith(CONTROLS_DIR + sep) &&
|
||||
read(file).includes('var(--pc-progress')
|
||||
);
|
||||
expect(readers.map((file) => relative(LIB_DIR, file)).sort()).toEqual([
|
||||
'fullscreen-episode-panel/fullscreen-episode-panel.component.scss',
|
||||
'portal-inline-player/up-next-rail.component.scss',
|
||||
]);
|
||||
for (const file of readers) {
|
||||
expect({ file, declared: declaresOnHost(read(file)) }).toEqual({
|
||||
file,
|
||||
declared: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('gives ArtPlayer the same colour as the Sass palette', () => {
|
||||
const accent = PALETTE.match(/\$accent-blue:\s*(#[0-9a-f]{6});/i)?.[1];
|
||||
|
||||
expect(accent).toBeDefined();
|
||||
expect(PALETTE).toMatch(
|
||||
/@mixin progress-token\s*\{\s*--pc-progress:\s*#\{\$accent-blue\};\s*\}/
|
||||
);
|
||||
expect(PLAYER_PROGRESS_COLOR.toLowerCase()).toBe(accent?.toLowerCase());
|
||||
expect(
|
||||
read(resolve(LIB_DIR, 'art-player/art-player.component.ts'))
|
||||
).toMatch(/theme:\s*PLAYER_PROGRESS_COLOR,/);
|
||||
});
|
||||
|
||||
it('keeps the old progress reds out of the player', () => {
|
||||
const sources = [
|
||||
...STYLESHEETS,
|
||||
...listSources(LIB_DIR, '.ts'),
|
||||
...listSources(LIB_DIR, '.html'),
|
||||
];
|
||||
const offenders = sources
|
||||
.filter((file) => /#e50914|#ff0000\b/i.test(read(file)))
|
||||
.map((file) => relative(LIB_DIR, file));
|
||||
|
||||
expect(sources.length).toBeGreaterThan(50);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
.player-controls__timeline-fill {
|
||||
height: 100%;
|
||||
background: var(--pc-accent-blue);
|
||||
background: var(--pc-progress);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,8 @@
|
||||
margin: -7px 0 0 -7px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 5px rgba(79, 142, 255, 0.3);
|
||||
box-shadow: 0 0 0 5px
|
||||
color-mix(in srgb, var(--pc-progress) 30%, transparent);
|
||||
pointer-events: none;
|
||||
transition: transform 120ms ease-out;
|
||||
}
|
||||
@@ -228,6 +229,7 @@
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
margin: -5.5px 0 0 -5.5px;
|
||||
box-shadow: 0 0 0 4px rgba(79, 142, 255, 0.3);
|
||||
box-shadow: 0 0 0 4px
|
||||
color-mix(in srgb, var(--pc-progress) 30%, transparent);
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@
|
||||
.player-up-next__progress-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--pc-accent-blue, #4f8eff);
|
||||
background: var(--pc-progress);
|
||||
}
|
||||
|
||||
.player-up-next__meta {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
@use '../player-controls/player-palette' as palette;
|
||||
|
||||
// Opaque panel docked on the stage's right column — it sits on top of the
|
||||
// black stage (or the ambient fill), so its own dark surface works for both
|
||||
// app themes without :host-context branching.
|
||||
// app themes without :host-context branching. Watch progress takes the
|
||||
// player's `--pc-progress`, so an episode's bar matches the dock's Up next card.
|
||||
:host {
|
||||
@include palette.progress-token;
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -112,7 +116,7 @@
|
||||
.up-next__progress-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #e50914;
|
||||
background: var(--pc-progress);
|
||||
}
|
||||
|
||||
.up-next__meta {
|
||||
|
||||
@@ -328,7 +328,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--app-selection-color, #78adff);
|
||||
background: var(--app-progress-color);
|
||||
// A full-width fill slid into place, neither a width nor animated: a
|
||||
// live-EPG tick moves it by under one percent, so an animation was
|
||||
// invisible yet cost a layout pass (width) or a burst of compositor
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Watch progress in app chrome reads one theme token, so a title's bar on a
|
||||
// rail card, in the hero and in a catalog grid is the same colour. Live
|
||||
// programme progress keeps the live accent (see the UI guidelines).
|
||||
function styles(file: string): string {
|
||||
return readFileSync(resolve(__dirname, file), 'utf8');
|
||||
}
|
||||
|
||||
/** The declarations of the first `selector { … }` rule, nesting excluded. */
|
||||
function ruleBody(source: string, selector: RegExp): string {
|
||||
return (
|
||||
source.match(new RegExp(`${selector.source}\\s*\\{([^}]*)\\}`))?.[1] ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
describe('dashboard watch-progress colour', () => {
|
||||
it('fills the rail card artwork bar with the watch-progress token', () => {
|
||||
const rail = styles('dashboard-rail.component.scss');
|
||||
const bar = rail.slice(rail.indexOf('.rail__art-progress {'));
|
||||
|
||||
expect(ruleBody(bar, /\bi/)).toMatch(
|
||||
/background:\s*var\(--app-progress-color\);/
|
||||
);
|
||||
});
|
||||
|
||||
it('fills the hero resume bar with the token and live bars with the live accent', () => {
|
||||
const hero = styles('dashboard-hero.component.scss');
|
||||
const bar = hero.slice(hero.indexOf('.hero__progress {'));
|
||||
|
||||
expect(ruleBody(bar, /\bi/)).toMatch(
|
||||
/background:\s*var\(--app-progress-color\);/
|
||||
);
|
||||
expect(ruleBody(bar, /&--live i/)).toMatch(
|
||||
/background:\s*var\(--app-live-color\b/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -273,7 +273,7 @@
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--app-selection-color, #78adff);
|
||||
background: var(--app-progress-color);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -79,6 +79,9 @@
|
||||
"serve:website": "nx serve website",
|
||||
"build:website": "nx build website",
|
||||
"i18n:check": "node tools/i18n/check-drift.mjs",
|
||||
"i18n:test": "node --test tools/i18n/check-drift.test.mjs",
|
||||
"i18n:validate": "pnpm run i18n:test && pnpm run i18n:check",
|
||||
"i18n:baseline:update": "node tools/i18n/check-drift.mjs --update-baseline",
|
||||
"perf:initial-bytes": "node tools/performance/measure-initial-bytes.mjs",
|
||||
"perf:initial-bytes:check": "node tools/performance/measure-initial-bytes.mjs --summary dist/performance/initial-bytes.summary.json && node tools/performance/check-journey-ratchet.mjs --summary dist/performance/initial-bytes.summary.json --only launch/renderer.initialBytes",
|
||||
"perf:journeys": "nx run electron-backend-e2e:journeys",
|
||||
@@ -133,7 +136,6 @@
|
||||
"electron-conf": "1.3.0",
|
||||
"electron-updater": "6.8.9",
|
||||
"epg-parser": "^0.5.0",
|
||||
"fix-path": "5.0.0",
|
||||
"hls.js": "1.7.1",
|
||||
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.2",
|
||||
"marked": "18.0.11",
|
||||
@@ -145,6 +147,7 @@
|
||||
"rxjs": "7.8.2",
|
||||
"saxes": "6.0.0",
|
||||
"shaka-player": "5.2.4",
|
||||
"shell-path": "3.1.0",
|
||||
"video.js": "8.24.0",
|
||||
"videojs-contrib-quality-levels": "4.1.0",
|
||||
"videojs-quality-selector-hls": "1.1.1",
|
||||
|
||||
Generated
+4
-28
@@ -145,9 +145,6 @@ importers:
|
||||
epg-parser:
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
fix-path:
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0
|
||||
hls.js:
|
||||
specifier: 1.7.1
|
||||
version: 1.7.1
|
||||
@@ -181,6 +178,9 @@ importers:
|
||||
shaka-player:
|
||||
specifier: 5.2.4
|
||||
version: 5.2.4
|
||||
shell-path:
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0
|
||||
video.js:
|
||||
specifier: 8.24.0
|
||||
version: 8.24.0
|
||||
@@ -4774,10 +4774,6 @@ packages:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-regex@6.2.2:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-regex@6.3.0:
|
||||
resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -6210,10 +6206,6 @@ packages:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
fix-path@5.0.0:
|
||||
resolution: {integrity: sha512-erEWGGCN7RIu1bXTCfNVpVBdm0f5mwcbeja+4QXiEZzIQukP401sbpu8gd3Ny1vS34YNeswyMO0TdT2tP5OlHA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
flat-cache@4.0.1:
|
||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -8864,7 +8856,6 @@ packages:
|
||||
|
||||
shaka-player@5.2.4:
|
||||
resolution: {integrity: sha512-vf81av2EIcb03jRpeeZBhrPT3PMyggXnZA9k4sxGBxpr/H6v0iEL6b51T2Kwz5YNrQG/yDYoadgBW+oW40LeoA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
shallow-clone@3.0.1:
|
||||
resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}
|
||||
@@ -9050,10 +9041,6 @@ packages:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -14260,8 +14247,6 @@ snapshots:
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-regex@6.3.0: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
@@ -15972,11 +15957,6 @@ snapshots:
|
||||
locate-path: 6.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
fix-path@5.0.0:
|
||||
dependencies:
|
||||
shell-path: 3.1.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
flat-cache@4.0.1:
|
||||
dependencies:
|
||||
flatted: 3.4.2
|
||||
@@ -19620,7 +19600,7 @@ snapshots:
|
||||
dependencies:
|
||||
default-shell: 2.2.0
|
||||
execa: 5.1.1
|
||||
strip-ansi: 7.1.2
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
shell-path@3.1.0:
|
||||
dependencies:
|
||||
@@ -19812,10 +19792,6 @@ snapshots:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
dependencies:
|
||||
ansi-regex: 6.3.0
|
||||
|
||||
+18
-4
@@ -49,10 +49,24 @@ For a non-mutating CI/agent check, run:
|
||||
pnpm run i18n:check
|
||||
```
|
||||
|
||||
The check fails on missing or extra keys against `en.json`. It reports values
|
||||
that are still identical to English as warnings so untranslated fallback strings
|
||||
remain visible without blocking key-parity validation. For stricter translation
|
||||
audits, run:
|
||||
The check fails on missing or extra keys against `en.json`, and on values that
|
||||
are identical to English unless `identical-en-baseline.json` lists that exact
|
||||
English text for the locale and key. Do not copy English into a locale as a
|
||||
placeholder: translate it. The contract, including when an entry stops
|
||||
covering a key, is in the
|
||||
[validation map](../../docs/architecture/validation-map.md#i18n).
|
||||
|
||||
Baseline entries that are no longer identical are reported, not fatal. After
|
||||
translating baselined values, or for a value that is legitimately the same in a
|
||||
language (a brand, a technical name, a loanword), rewrite the baseline and
|
||||
review every added entry in the diff:
|
||||
|
||||
```bash
|
||||
pnpm run i18n:baseline:update
|
||||
```
|
||||
|
||||
The checker's own tests run with `pnpm run i18n:test`; CI runs both through
|
||||
`pnpm run i18n:validate`. For a full audit that ignores the baseline, run:
|
||||
|
||||
```bash
|
||||
node tools/i18n/check-drift.mjs --fail-on-identical
|
||||
|
||||
+291
-73
@@ -1,27 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
/**
|
||||
* Compares every locale in apps/web/src/assets/i18n/ with en.json.
|
||||
*
|
||||
* Missing or extra keys fail. A value identical to English fails too, unless
|
||||
* tools/i18n/identical-en-baseline.json records that exact English text for
|
||||
* that locale and key. The baseline holds values that are legitimately the
|
||||
* same in a language (brands, technical terms, loanwords such as "PIN") and
|
||||
* the untranslated debt that existed when the guard was introduced, so new
|
||||
* keys must ship translated while old debt can be paid down.
|
||||
*
|
||||
* Baseline entries that no longer match an English-identical value are only
|
||||
* reported. `--update-baseline` rewrites the baseline from the current files;
|
||||
* run it deliberately and review the diff. CI never runs it.
|
||||
* `--fail-on-identical` ignores the baseline for a strict audit.
|
||||
*/
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, '..', '..');
|
||||
const I18N_DIR = resolve(REPO_ROOT, 'apps/web/src/assets/i18n');
|
||||
const EN_LOCALE = 'en.json';
|
||||
export const I18N_DIR = resolve(__dirname, '../../apps/web/src/assets/i18n');
|
||||
export const BASELINE_PATH = resolve(__dirname, 'identical-en-baseline.json');
|
||||
const EN_LOCALE = 'en';
|
||||
const MAX_EXAMPLES = 5;
|
||||
const FAIL_ON_IDENTICAL = process.argv.includes('--fail-on-identical');
|
||||
const UPDATE_COMMAND = 'pnpm run i18n:baseline:update';
|
||||
const FLAGS = new Set(['--fail-on-identical', '--update-baseline', '--']);
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function collectLeaves(value, prefix = '', leaves = new Map()) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
function isPlainObject(value) {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function collectLeaves(value, prefix = '', leaves = new Map()) {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error(`Expected object at ${prefix || '<root>'}`);
|
||||
}
|
||||
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
||||
if (isPlainObject(entry)) {
|
||||
collectLeaves(entry, path, leaves);
|
||||
} else {
|
||||
leaves.set(path, entry);
|
||||
@@ -31,10 +51,87 @@ function collectLeaves(value, prefix = '', leaves = new Map()) {
|
||||
return leaves;
|
||||
}
|
||||
|
||||
function listLocaleFiles() {
|
||||
return readdirSync(I18N_DIR)
|
||||
.filter((file) => file.endsWith('.json'))
|
||||
.sort();
|
||||
/** Keys whose non-empty locale string equals the English string. */
|
||||
export function findIdenticalToEnglish(enLeaves, localeLeaves) {
|
||||
const identical = new Map();
|
||||
for (const [key, enValue] of enLeaves) {
|
||||
const localeValue = localeLeaves.get(key);
|
||||
if (
|
||||
typeof enValue === 'string' &&
|
||||
localeValue === enValue &&
|
||||
enValue.trim().length > 0
|
||||
) {
|
||||
identical.set(key, enValue);
|
||||
}
|
||||
}
|
||||
return identical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a locale's English-identical keys into baselined and new ones. An
|
||||
* entry covers a key only while its recorded text is still the English text,
|
||||
* so rewording English and copying the new text into a locale is new debt.
|
||||
* Stale entries no longer describe an English-identical value: the key was
|
||||
* translated or removed, or its English text changed.
|
||||
*/
|
||||
export function compareWithBaseline(identical, localeBaseline = new Map()) {
|
||||
const baselined = [];
|
||||
const unbaselined = [];
|
||||
for (const [key, value] of identical) {
|
||||
(localeBaseline.get(key) === value ? baselined : unbaselined).push(key);
|
||||
}
|
||||
const stale = [...localeBaseline]
|
||||
.filter(([key, value]) => identical.get(key) !== value)
|
||||
.map(([key]) => key);
|
||||
return { baselined, unbaselined, stale };
|
||||
}
|
||||
|
||||
/** Validates the baseline JSON into Map<locale, Map<key, englishText>>. */
|
||||
export function parseBaseline(raw) {
|
||||
if (!isPlainObject(raw)) {
|
||||
throw new Error('expected an object of locales');
|
||||
}
|
||||
const baseline = new Map();
|
||||
for (const [locale, entries] of Object.entries(raw)) {
|
||||
if (!isPlainObject(entries)) {
|
||||
throw new Error(`expected an object of keys for ${locale}`);
|
||||
}
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`expected English text at ${locale} ${key}`);
|
||||
}
|
||||
}
|
||||
baseline.set(locale, new Map(Object.entries(entries)));
|
||||
}
|
||||
return baseline;
|
||||
}
|
||||
|
||||
/** Serialises Map<locale, Map<key, text>> with locales and keys sorted. */
|
||||
export function serializeBaseline(identicalByLocale) {
|
||||
const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const output = {};
|
||||
for (const locale of [...identicalByLocale.keys()].sort(byCodeUnit)) {
|
||||
const entries = identicalByLocale.get(locale);
|
||||
if (entries.size > 0) {
|
||||
output[locale] = Object.fromEntries(
|
||||
[...entries].sort(([a], [b]) => byCodeUnit(a, b))
|
||||
);
|
||||
}
|
||||
}
|
||||
return `${JSON.stringify(output, null, 4)}\n`;
|
||||
}
|
||||
|
||||
function countChanges(from, to, onChange = () => undefined) {
|
||||
let changes = 0;
|
||||
for (const [locale, entries] of from) {
|
||||
for (const [key, value] of entries) {
|
||||
if (to.get(locale)?.get(key) !== value) {
|
||||
changes += 1;
|
||||
onChange(locale, key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function formatExamples(values) {
|
||||
@@ -50,73 +147,194 @@ function formatError(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
let enLeaves;
|
||||
try {
|
||||
enLeaves = collectLeaves(readJson(resolve(I18N_DIR, EN_LOCALE)));
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${EN_LOCALE}: ${formatError(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const enKeys = [...enLeaves.keys()];
|
||||
let failed = false;
|
||||
let identicalTotal = 0;
|
||||
|
||||
for (const localeFile of listLocaleFiles()) {
|
||||
if (localeFile === EN_LOCALE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
function readBaseline(baselinePath) {
|
||||
let raw;
|
||||
try {
|
||||
const localeLeaves = collectLeaves(
|
||||
readJson(resolve(I18N_DIR, localeFile))
|
||||
);
|
||||
const localeKeys = [...localeLeaves.keys()];
|
||||
const missing = enKeys.filter((key) => !localeLeaves.has(key));
|
||||
const extra = localeKeys.filter((key) => !enLeaves.has(key));
|
||||
const identical = enKeys.filter((key) => {
|
||||
if (!localeLeaves.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const localeValue = localeLeaves.get(key);
|
||||
const enValue = enLeaves.get(key);
|
||||
|
||||
return (
|
||||
typeof localeValue === 'string' &&
|
||||
typeof enValue === 'string' &&
|
||||
localeValue.trim().length > 0 &&
|
||||
localeValue === enValue
|
||||
);
|
||||
});
|
||||
|
||||
identicalTotal += identical.length;
|
||||
const localeFailed =
|
||||
missing.length > 0 ||
|
||||
extra.length > 0 ||
|
||||
(FAIL_ON_IDENTICAL && identical.length > 0);
|
||||
failed = failed || localeFailed;
|
||||
|
||||
const status = localeFailed ? 'FAIL' : 'ok';
|
||||
console.log(
|
||||
`${status} ${localeFile}: missing=${missing.length}${formatExamples(
|
||||
missing
|
||||
)} extra=${extra.length}${formatExamples(extra)} identical_en=${
|
||||
identical.length
|
||||
}${formatExamples(identical)}`
|
||||
);
|
||||
raw = readJson(baselinePath);
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
console.log(`FAIL ${localeFile}: ${formatError(error)}`);
|
||||
if (error?.code === 'ENOENT') {
|
||||
return new Map();
|
||||
}
|
||||
throw new Error(`${baselinePath}: ${formatError(error)}`);
|
||||
}
|
||||
try {
|
||||
return parseBaseline(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`${baselinePath}: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (identicalTotal > 0 && !FAIL_ON_IDENTICAL) {
|
||||
console.log(
|
||||
`warn identical_en=${identicalTotal}; pass --fail-on-identical to make English fallback values fatal.`
|
||||
function listLocales(i18nDir) {
|
||||
return readdirSync(i18nDir)
|
||||
.filter((file) => file.endsWith('.json'))
|
||||
.map((file) => file.slice(0, -'.json'.length))
|
||||
.filter((locale) => locale !== EN_LOCALE)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function checkLocale({ i18nDir, locale, enLeaves, baseline, mode }) {
|
||||
const localeLeaves = collectLeaves(
|
||||
readJson(resolve(i18nDir, `${locale}.json`))
|
||||
);
|
||||
const missing = [...enLeaves.keys()].filter(
|
||||
(key) => !localeLeaves.has(key)
|
||||
);
|
||||
const extra = [...localeLeaves.keys()].filter((key) => !enLeaves.has(key));
|
||||
const identical = findIdenticalToEnglish(enLeaves, localeLeaves);
|
||||
const { baselined, unbaselined, stale } = compareWithBaseline(
|
||||
identical,
|
||||
baseline.get(locale)
|
||||
);
|
||||
const rejected =
|
||||
mode === 'strict'
|
||||
? [...identical.keys()]
|
||||
: mode === 'update'
|
||||
? []
|
||||
: unbaselined;
|
||||
return { missing, extra, identical, baselined, rejected, stale };
|
||||
}
|
||||
|
||||
function reportLocale(log, file, result, mode) {
|
||||
const { missing, extra, identical, baselined, rejected, stale } = result;
|
||||
const failed = missing.length + extra.length + rejected.length > 0;
|
||||
const baselineNote =
|
||||
mode === 'baseline' ? ` baselined=${baselined.length}` : '';
|
||||
log(
|
||||
`${failed ? 'FAIL' : 'ok'} ${file}: missing=${
|
||||
missing.length
|
||||
}${formatExamples(missing)} extra=${extra.length}${formatExamples(
|
||||
extra
|
||||
)} identical_en=${identical.size}${baselineNote}`
|
||||
);
|
||||
if (mode === 'baseline' && stale.length > 0) {
|
||||
log(
|
||||
`note ${file}: ${stale.length} baseline entries are no longer English-identical${formatExamples(
|
||||
stale
|
||||
)}`
|
||||
);
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
function reportRejected(log, rejected, enLeaves, mode) {
|
||||
if (rejected.length === 0) {
|
||||
return;
|
||||
}
|
||||
const scope = mode === 'strict' ? '' : ' and are not in the baseline';
|
||||
log(`${rejected.length} value(s) equal English${scope}:`);
|
||||
for (const [file, key] of rejected) {
|
||||
log(` ${file} ${key}: ${JSON.stringify(enLeaves.get(key))}`);
|
||||
}
|
||||
if (mode === 'baseline') {
|
||||
log(
|
||||
`Translate them (see the i18n-fill skill). Only if a value is legitimately the same in that language, run \`${UPDATE_COMMAND}\` and commit the reviewed baseline.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function writeBaseline(log, baselinePath, previous, identicalByLocale) {
|
||||
const added = countChanges(
|
||||
identicalByLocale,
|
||||
previous,
|
||||
(locale, key, value) =>
|
||||
log(` + ${locale} ${key}: ${JSON.stringify(value)}`)
|
||||
);
|
||||
const removed = countChanges(previous, identicalByLocale);
|
||||
writeFileSync(baselinePath, serializeBaseline(identicalByLocale));
|
||||
log(
|
||||
`Baseline written: ${added} added, ${removed} removed. Review every added entry before committing.`
|
||||
);
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exitCode = 1;
|
||||
/** Runs the check and returns the process exit code. */
|
||||
export function run({
|
||||
i18nDir = I18N_DIR,
|
||||
baselinePath = BASELINE_PATH,
|
||||
argv = [],
|
||||
log = console.log,
|
||||
} = {}) {
|
||||
const strict = argv.includes('--fail-on-identical');
|
||||
const update = argv.includes('--update-baseline');
|
||||
if (argv.some((arg) => !FLAGS.has(arg)) || (strict && update)) {
|
||||
log(
|
||||
`FAIL unsupported arguments: ${argv.join(' ')}; pass at most one of --fail-on-identical and --update-baseline.`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const mode = strict ? 'strict' : update ? 'update' : 'baseline';
|
||||
|
||||
let enLeaves;
|
||||
let baseline;
|
||||
try {
|
||||
enLeaves = collectLeaves(
|
||||
readJson(resolve(i18nDir, `${EN_LOCALE}.json`))
|
||||
);
|
||||
// A strict audit ignores the baseline, so a damaged file cannot block it.
|
||||
baseline = strict ? new Map() : readBaseline(baselinePath);
|
||||
} catch (error) {
|
||||
if (!enLeaves || !update) {
|
||||
log(`FAIL ${formatError(error)}`);
|
||||
return 1;
|
||||
}
|
||||
log(`note replacing unreadable baseline: ${formatError(error)}`);
|
||||
baseline = new Map();
|
||||
}
|
||||
|
||||
const locales = listLocales(i18nDir);
|
||||
const identicalByLocale = new Map();
|
||||
const rejected = [];
|
||||
let failed = false;
|
||||
let staleTotal = 0;
|
||||
|
||||
for (const locale of locales) {
|
||||
const file = `${locale}.json`;
|
||||
let result;
|
||||
try {
|
||||
result = checkLocale({ i18nDir, locale, enLeaves, baseline, mode });
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
log(`FAIL ${file}: ${formatError(error)}`);
|
||||
continue;
|
||||
}
|
||||
identicalByLocale.set(locale, result.identical);
|
||||
rejected.push(...result.rejected.map((key) => [file, key]));
|
||||
staleTotal += result.stale.length;
|
||||
failed = reportLocale(log, file, result, mode) || failed;
|
||||
}
|
||||
|
||||
if (mode === 'baseline') {
|
||||
for (const [locale, entries] of baseline) {
|
||||
if (!locales.includes(locale)) {
|
||||
staleTotal += entries.size;
|
||||
log(`note ${locale}: baseline lists a locale with no file`);
|
||||
}
|
||||
}
|
||||
if (staleTotal > 0) {
|
||||
log(
|
||||
`note ${staleTotal} stale baseline entries; run \`${UPDATE_COMMAND}\` to shrink the baseline.`
|
||||
);
|
||||
}
|
||||
}
|
||||
reportRejected(log, rejected, enLeaves, mode);
|
||||
|
||||
if (update) {
|
||||
// Only a complete, readable set of locales is a state worth recording.
|
||||
if (failed) {
|
||||
log(
|
||||
'FAIL baseline not written: fix the unreadable, missing or extra keys above first.'
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
writeBaseline(log, baselinePath, baseline, identicalByLocale);
|
||||
}
|
||||
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
||||
|
||||
if (isMain) {
|
||||
process.exitCode = run({ argv: process.argv.slice(2) });
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, test } from 'node:test';
|
||||
|
||||
import { parseBaseline, run, serializeBaseline } from './check-drift.mjs';
|
||||
|
||||
const EN = {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Save' },
|
||||
PIN: 'PIN',
|
||||
};
|
||||
|
||||
let root;
|
||||
let dir;
|
||||
let baselinePath;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(path.join(os.tmpdir(), 'i18n-drift-'));
|
||||
dir = path.join(root, 'i18n');
|
||||
mkdirSync(dir);
|
||||
baselinePath = path.join(root, 'identical-en-baseline.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeLocale(locale, content) {
|
||||
writeFileSync(path.join(dir, `${locale}.json`), JSON.stringify(content));
|
||||
}
|
||||
|
||||
function writeBaseline(content) {
|
||||
writeFileSync(baselinePath, JSON.stringify(content));
|
||||
}
|
||||
|
||||
function check(...argv) {
|
||||
const lines = [];
|
||||
const code = run({
|
||||
i18nDir: dir,
|
||||
baselinePath,
|
||||
argv,
|
||||
log: (line) => lines.push(line),
|
||||
});
|
||||
return { code, output: lines.join('\n') };
|
||||
}
|
||||
|
||||
function readBaselineFile() {
|
||||
return JSON.parse(readFileSync(baselinePath, 'utf8'));
|
||||
}
|
||||
|
||||
test('fails on a new English-identical key that the baseline does not list', () => {
|
||||
writeLocale('en', { ...EN, APP: { ...EN.APP, NEW: 'New label' } });
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern', NEW: 'New label' },
|
||||
PIN: 'PIN',
|
||||
});
|
||||
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
|
||||
|
||||
const { code, output } = check();
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(output, /^FAIL de\.json: .*identical_en=3 baselined=2$/m);
|
||||
assert.match(output, /de\.json APP\.NEW: "New label"/);
|
||||
});
|
||||
|
||||
test('passes a translated key', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||
PIN: 'PIN-Code',
|
||||
});
|
||||
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator' } });
|
||||
|
||||
const { code, output } = check();
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.match(output, /^ok de\.json: .*identical_en=1 baselined=1$/m);
|
||||
});
|
||||
|
||||
test('passes an English-identical key listed in the baseline', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||
PIN: 'PIN',
|
||||
});
|
||||
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
|
||||
|
||||
assert.equal(check().code, 0);
|
||||
});
|
||||
|
||||
test('a baseline entry only covers the English text it recorded', () => {
|
||||
// English was reworded and the new text copied into the locale: that is
|
||||
// new untranslated text, not the value the baseline accepted.
|
||||
writeLocale('en', { ...EN, APP: { ...EN.APP, SAVE: 'Save changes' } });
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Save changes' },
|
||||
PIN: 'PIN-Code',
|
||||
});
|
||||
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', 'APP.SAVE': 'Save' } });
|
||||
|
||||
const { code, output } = check();
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(output, /de\.json APP\.SAVE: "Save changes"/);
|
||||
});
|
||||
|
||||
test('reports baseline entries that are no longer identical without failing', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||
PIN: 'PIN-Code',
|
||||
});
|
||||
writeBaseline({
|
||||
de: { 'APP.TITLE': 'IPTVnator', 'APP.SAVE': 'Save', PIN: 'PIN' },
|
||||
fr: { PIN: 'PIN' },
|
||||
});
|
||||
|
||||
const { code, output } = check();
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.match(
|
||||
output,
|
||||
/^note de\.json: 2 baseline entries .*\(APP\.SAVE, PIN\)$/m
|
||||
);
|
||||
assert.match(output, /^note fr: baseline lists a locale with no file$/m);
|
||||
assert.match(
|
||||
output,
|
||||
/^note 3 stale baseline entries; run .*i18n:baseline:update/m
|
||||
);
|
||||
});
|
||||
|
||||
test('a locale without baseline entries fails on every identical value', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('uk', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Зберегти' },
|
||||
PIN: 'PIN-код',
|
||||
});
|
||||
|
||||
const { code, output } = check();
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(output, /uk\.json APP\.TITLE: "IPTVnator"/);
|
||||
});
|
||||
|
||||
test('still fails on missing and extra keys', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', { APP: { TITLE: 'IPTVnator', OLD: 'Alt' }, PIN: 'PIN' });
|
||||
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
|
||||
|
||||
const { code, output } = check();
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(
|
||||
output,
|
||||
/^FAIL de\.json: missing=1 \(APP\.SAVE\) extra=1 \(APP\.OLD\)/m
|
||||
);
|
||||
});
|
||||
|
||||
test('--fail-on-identical ignores the baseline', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||
PIN: 'PIN',
|
||||
});
|
||||
writeBaseline({ de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' } });
|
||||
|
||||
const { code, output } = check('--fail-on-identical');
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(output, /de\.json APP\.TITLE: "IPTVnator"/);
|
||||
assert.match(output, /de\.json PIN: "PIN"/);
|
||||
});
|
||||
|
||||
test('--update-baseline records current identical values and drops resolved ones', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||
PIN: 'PIN',
|
||||
});
|
||||
writeLocale('fr', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Enregistrer' },
|
||||
PIN: 'Code PIN',
|
||||
});
|
||||
writeBaseline({ de: { 'APP.SAVE': 'Save' }, fr: { PIN: 'PIN' } });
|
||||
|
||||
const { code, output } = check('--update-baseline');
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.match(output, /^ {2}\+ de PIN: "PIN"$/m);
|
||||
assert.match(output, /Baseline written: 3 added, 2 removed\./);
|
||||
assert.deepEqual(readBaselineFile(), {
|
||||
de: { 'APP.TITLE': 'IPTVnator', PIN: 'PIN' },
|
||||
fr: { 'APP.TITLE': 'IPTVnator' },
|
||||
});
|
||||
assert.equal(check().code, 0);
|
||||
});
|
||||
|
||||
test('--update-baseline keeps the old baseline when a locale is unreadable', () => {
|
||||
writeLocale('en', EN);
|
||||
writeFileSync(path.join(dir, 'de.json'), '{ broken');
|
||||
writeBaseline({ de: { PIN: 'PIN' } });
|
||||
|
||||
const { code, output } = check('--update-baseline');
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(output, /baseline not written/);
|
||||
assert.deepEqual(readBaselineFile(), { de: { PIN: 'PIN' } });
|
||||
});
|
||||
|
||||
test('--update-baseline keeps the old baseline while keys are missing or extra', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', { APP: { TITLE: 'IPTVnator', OLD: 'Alt' }, PIN: 'PIN' });
|
||||
writeBaseline({ de: { 'APP.SAVE': 'Save', PIN: 'PIN' } });
|
||||
|
||||
const { code, output } = check('--update-baseline');
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(output, /baseline not written/);
|
||||
assert.deepEqual(readBaselineFile(), {
|
||||
de: { 'APP.SAVE': 'Save', PIN: 'PIN' },
|
||||
});
|
||||
});
|
||||
|
||||
test('--fail-on-identical audits even when the baseline is damaged', () => {
|
||||
writeLocale('en', EN);
|
||||
writeLocale('de', {
|
||||
APP: { TITLE: 'IPTVnator', SAVE: 'Speichern' },
|
||||
PIN: 'PIN-Code',
|
||||
});
|
||||
writeFileSync(baselinePath, '{ broken');
|
||||
|
||||
const { code, output } = check('--fail-on-identical');
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.match(output, /^FAIL de\.json: missing=0 extra=0 identical_en=1$/m);
|
||||
assert.match(output, /de\.json APP\.TITLE: "IPTVnator"/);
|
||||
assert.equal(check().code, 1);
|
||||
});
|
||||
|
||||
test('rejects unknown and conflicting flags', () => {
|
||||
writeLocale('en', EN);
|
||||
|
||||
assert.equal(check('--update').code, 1);
|
||||
assert.equal(check('--fail-on-identical', '--update-baseline').code, 1);
|
||||
});
|
||||
|
||||
test('rejects a malformed baseline', () => {
|
||||
assert.throws(() => parseBaseline({ de: ['PIN'] }), /object of keys/);
|
||||
assert.throws(() => parseBaseline({ de: { PIN: 1 } }), /English text/);
|
||||
});
|
||||
|
||||
test('serialises locales and keys in a stable order', () => {
|
||||
const serialized = serializeBaseline(
|
||||
new Map([
|
||||
[
|
||||
'fr',
|
||||
new Map([
|
||||
['B', 'b'],
|
||||
['A', 'a'],
|
||||
]),
|
||||
],
|
||||
['de', new Map()],
|
||||
['ar', new Map([['Z', 'z']])],
|
||||
])
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
serialized,
|
||||
'{\n "ar": {\n "Z": "z"\n },\n "fr": {\n "A": "a",\n "B": "b"\n }\n}\n'
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user