mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
Merge origin/master into claude/parental-control-feature-31dde2
Resolves the settings-store defaults split, the electron-conf key list and the guidance reorganization (CLAUDE.md now imports AGENTS.md; the parental lock contract is linked from the agent context map instead). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
ae5d3f49de
732 files changed
+42572
-15380
No files matched your search
@@ -1,3 +1,4 @@
|
||||
export * from './lib/app-update-channel.util';
|
||||
export * from './lib/channel-drm.interface';
|
||||
export * from './lib/channel.interface';
|
||||
export * from './lib/channel.model';
|
||||
@@ -6,6 +7,7 @@ export * from './lib/dev-logger.util';
|
||||
export * from './lib/download-metadata.interface';
|
||||
export * from './lib/embedded-mpv-session.interface';
|
||||
export * from './lib/subtitle-style.util';
|
||||
export * from './lib/zoom-level.util';
|
||||
export * from './lib/electron-api.interface';
|
||||
export * from './lib/epg-channel-metadata.model';
|
||||
export * from './lib/epg-channel-with-programs.interface';
|
||||
@@ -14,6 +16,7 @@ export * from './lib/epg-display-offset.util';
|
||||
export * from './lib/epg-item.interface';
|
||||
export * from './lib/epg-mapping-key.util';
|
||||
export * from './lib/epg-program.model';
|
||||
export * from './lib/epg-source-reference.util';
|
||||
export * from './lib/external-player-arguments.utils';
|
||||
export * from './lib/embedded-mpv-extra-options.util';
|
||||
export * from './lib/external-player-session.interface';
|
||||
@@ -47,6 +50,7 @@ export * from './lib/provider-overview.util';
|
||||
export * from './lib/random-id.util';
|
||||
export * from './lib/recording-metadata.interface';
|
||||
export * from './lib/recording-program-overlap.util';
|
||||
export * from './lib/renderer-reload-route.util';
|
||||
export * from './lib/security-policy-error.utils';
|
||||
export * from './lib/settings.interface';
|
||||
export * from './lib/stalker-auth-failure.util';
|
||||
@@ -58,6 +62,7 @@ export * from './lib/stream-format.enum';
|
||||
export * from './lib/catalog-title-match.interface';
|
||||
export * from './lib/theme.enum';
|
||||
export * from './lib/season-marker.util';
|
||||
export * from './lib/search-text-fold.util';
|
||||
export * from './lib/stalker-account-info-dialog-data.interface';
|
||||
export * from './lib/title-normalization.util';
|
||||
export * from './lib/tmdb.interface';
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
appUpdateReleasesApiUrl,
|
||||
appUpdateReleasesListUrl,
|
||||
appUpdateReleasesPageUrl,
|
||||
buildAppUpdateReleaseNotesNotFoundMessage,
|
||||
isAppUpdateReleaseNotesNotFoundMessage,
|
||||
appUpdateRepository,
|
||||
appVersionChannel,
|
||||
compareAppVersions,
|
||||
normalizeAppUpdateChannel,
|
||||
parseAppVersion,
|
||||
} from './app-update-channel.util';
|
||||
|
||||
describe('app update channel util', () => {
|
||||
it('collapses unknown channels to stable', () => {
|
||||
expect(normalizeAppUpdateChannel('nightly')).toBe('nightly');
|
||||
expect(normalizeAppUpdateChannel('stable')).toBe('stable');
|
||||
expect(normalizeAppUpdateChannel('canary')).toBe('stable');
|
||||
expect(normalizeAppUpdateChannel(undefined)).toBe('stable');
|
||||
expect(normalizeAppUpdateChannel(null)).toBe('stable');
|
||||
});
|
||||
|
||||
it('maps each channel to its repository and pages', () => {
|
||||
expect(appUpdateRepository('stable')).toEqual({
|
||||
owner: '4gray',
|
||||
repo: 'iptvnator',
|
||||
});
|
||||
expect(appUpdateRepository('nightly')).toEqual({
|
||||
owner: '4gray',
|
||||
repo: 'iptvnator-nightly',
|
||||
});
|
||||
// /releases/latest never resolves to a prerelease.
|
||||
expect(appUpdateReleasesPageUrl('stable')).toBe(
|
||||
'https://github.com/4gray/iptvnator/releases/latest'
|
||||
);
|
||||
expect(appUpdateReleasesPageUrl('nightly')).toBe(
|
||||
'https://github.com/4gray/iptvnator-nightly/releases'
|
||||
);
|
||||
expect(appUpdateReleasesApiUrl('nightly')).toBe(
|
||||
'https://api.github.com/repos/4gray/iptvnator-nightly/releases'
|
||||
);
|
||||
expect(appUpdateReleasesListUrl('stable')).toBe(
|
||||
'https://github.com/4gray/iptvnator/releases'
|
||||
);
|
||||
expect(appUpdateReleasesListUrl('nightly')).toBe(
|
||||
'https://github.com/4gray/iptvnator-nightly/releases'
|
||||
);
|
||||
});
|
||||
|
||||
it('recognises the not-found rejection through the IPC wrapper', () => {
|
||||
const message = buildAppUpdateReleaseNotesNotFoundMessage('0.24.0');
|
||||
|
||||
expect(message).toBe('Release notes were not found for 0.24.0');
|
||||
expect(buildAppUpdateReleaseNotesNotFoundMessage(undefined)).toBe(
|
||||
'Release notes were not found for latest release'
|
||||
);
|
||||
expect(
|
||||
isAppUpdateReleaseNotesNotFoundMessage(
|
||||
`Error invoking remote method 'APP_UPDATE:GET_RELEASE_NOTES': Error: ${message}`
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAppUpdateReleaseNotesNotFoundMessage(
|
||||
'GitHub releases request failed: 403 Forbidden'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(isAppUpdateReleaseNotesNotFoundMessage(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the channel a build belongs to off its version', () => {
|
||||
expect(appVersionChannel('0.23.0')).toBe('stable');
|
||||
expect(appVersionChannel('v0.23.0')).toBe('stable');
|
||||
expect(appVersionChannel('0.23.1-nightly.20260915.1234')).toBe(
|
||||
'nightly'
|
||||
);
|
||||
expect(appVersionChannel('v0.23.1-nightly.20260915.1234')).toBe(
|
||||
'nightly'
|
||||
);
|
||||
expect(appVersionChannel('0.23.1-beta.1')).toBe('stable');
|
||||
expect(appVersionChannel('junk')).toBe('stable');
|
||||
expect(appVersionChannel(undefined)).toBe('stable');
|
||||
});
|
||||
|
||||
it('parses core and prerelease identifiers', () => {
|
||||
expect(parseAppVersion('v0.23.1-nightly.20260915.7+abc')).toEqual({
|
||||
core: [0, 23, 1],
|
||||
prerelease: ['nightly', '20260915', '7'],
|
||||
});
|
||||
expect(parseAppVersion('0.23')).toBeNull();
|
||||
});
|
||||
|
||||
it('orders versions by semver precedence', () => {
|
||||
expect(compareAppVersions('0.24.0', '0.23.0')).toBe(1);
|
||||
expect(compareAppVersions('0.23.0', '0.23.0')).toBe(0);
|
||||
// A nightly sits between the released version and the next patch.
|
||||
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.23.0')).toBe(
|
||||
1
|
||||
);
|
||||
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.23.1')).toBe(
|
||||
-1
|
||||
);
|
||||
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.24.0')).toBe(
|
||||
-1
|
||||
);
|
||||
// Same day: the run number decides numerically, not lexically.
|
||||
expect(
|
||||
compareAppVersions(
|
||||
'0.23.1-nightly.20260915.1000',
|
||||
'0.23.1-nightly.20260915.999'
|
||||
)
|
||||
).toBe(1);
|
||||
expect(
|
||||
compareAppVersions(
|
||||
'0.23.1-nightly.20260916.1',
|
||||
'0.23.1-nightly.20260915.999'
|
||||
)
|
||||
).toBe(1);
|
||||
// Fewer identifiers rank lower; numeric identifiers rank below words.
|
||||
expect(compareAppVersions('0.23.1-nightly', '0.23.1-nightly.1')).toBe(
|
||||
-1
|
||||
);
|
||||
expect(compareAppVersions('0.23.1-1', '0.23.1-alpha')).toBe(-1);
|
||||
// Junk never looks like an update.
|
||||
expect(compareAppVersions('junk', '0.23.0')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Desktop update channel. Electron only.
|
||||
*
|
||||
* `stable` follows the tagged releases of `4gray/iptvnator`; `nightly`
|
||||
* follows the prereleases that every master merge publishes to
|
||||
* `4gray/iptvnator-nightly`. The value is persisted with the other settings
|
||||
* in the renderer and mirrored into the main-process config by the
|
||||
* `SETTINGS_UPDATE` handler, because the startup update check runs before
|
||||
* any renderer exists to ask.
|
||||
*
|
||||
* Channel switches are forward-only on purpose: a nightly build stays
|
||||
* installed until the next stable version is newer than it. A downgrade
|
||||
* could land on a release that does not understand the database schema a
|
||||
* nightly migration already applied.
|
||||
*/
|
||||
export type AppUpdateChannel = 'stable' | 'nightly';
|
||||
|
||||
export const APP_UPDATE_CHANNELS: readonly AppUpdateChannel[] = [
|
||||
'stable',
|
||||
'nightly',
|
||||
];
|
||||
|
||||
export const DEFAULT_APP_UPDATE_CHANNEL: AppUpdateChannel = 'stable';
|
||||
|
||||
/**
|
||||
* Prerelease identifier every nightly version carries
|
||||
* (`0.23.1-nightly.20260915.1234`). The nightly build sets the same name as
|
||||
* electron-builder's publish channel, which names the updater metadata
|
||||
* (`nightly-mac.yml`, `nightly.yml`, `nightly-linux.yml`), and
|
||||
* electron-updater matches release tags on the same identifier.
|
||||
*/
|
||||
export const NIGHTLY_PRERELEASE_TAG = 'nightly';
|
||||
|
||||
/** Collapses anything that is not a known channel to `stable`. */
|
||||
export function normalizeAppUpdateChannel(value: unknown): AppUpdateChannel {
|
||||
return APP_UPDATE_CHANNELS.includes(value as AppUpdateChannel)
|
||||
? (value as AppUpdateChannel)
|
||||
: DEFAULT_APP_UPDATE_CHANNEL;
|
||||
}
|
||||
|
||||
interface AppUpdateRepository {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
const APP_UPDATE_REPOSITORIES: Record<AppUpdateChannel, AppUpdateRepository> = {
|
||||
stable: { owner: '4gray', repo: 'iptvnator' },
|
||||
nightly: { owner: '4gray', repo: 'iptvnator-nightly' },
|
||||
};
|
||||
|
||||
export function appUpdateRepository(
|
||||
channel: AppUpdateChannel
|
||||
): AppUpdateRepository {
|
||||
return { ...APP_UPDATE_REPOSITORIES[channel] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Page a user can open to install by hand. `/releases/latest` never resolves
|
||||
* to a prerelease, and the nightly repository holds nothing else, so that
|
||||
* channel links to the release list instead.
|
||||
*/
|
||||
export function appUpdateReleasesPageUrl(channel: AppUpdateChannel): string {
|
||||
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
|
||||
const base = `https://github.com/${owner}/${repo}/releases`;
|
||||
|
||||
return channel === 'stable' ? `${base}/latest` : base;
|
||||
}
|
||||
|
||||
/** The channel's release list; the page to browse when one version is missing. */
|
||||
export function appUpdateReleasesListUrl(channel: AppUpdateChannel): string {
|
||||
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
|
||||
|
||||
return `https://github.com/${owner}/${repo}/releases`;
|
||||
}
|
||||
|
||||
export function appUpdateReleasesApiUrl(channel: AppUpdateChannel): string {
|
||||
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
|
||||
|
||||
return `https://api.github.com/repos/${owner}/${repo}/releases`;
|
||||
}
|
||||
|
||||
const VERSION_PATTERN =
|
||||
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
interface ParsedAppVersion {
|
||||
core: [number, number, number];
|
||||
prerelease: string[];
|
||||
}
|
||||
|
||||
export function parseAppVersion(
|
||||
value: string | null | undefined
|
||||
): ParsedAppVersion | null {
|
||||
const match = (value ?? '').trim().match(VERSION_PATTERN);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
||||
prerelease: match[4] ? match[4].split('.') : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The channel a build belongs to, read from its version string. */
|
||||
export function appVersionChannel(
|
||||
version: string | null | undefined
|
||||
): AppUpdateChannel {
|
||||
return parseAppVersion(version)?.prerelease[0] === NIGHTLY_PRERELEASE_TAG
|
||||
? 'nightly'
|
||||
: 'stable';
|
||||
}
|
||||
|
||||
function compareIdentifiers(left: string, right: string): number {
|
||||
const leftNumeric = /^\d+$/.test(left);
|
||||
const rightNumeric = /^\d+$/.test(right);
|
||||
|
||||
if (leftNumeric && rightNumeric) {
|
||||
return Math.sign(Number(left) - Number(right));
|
||||
}
|
||||
|
||||
// Semver: numeric identifiers always have lower precedence.
|
||||
if (leftNumeric !== rightNumeric) {
|
||||
return leftNumeric ? -1 : 1;
|
||||
}
|
||||
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semver precedence for the version strings the updater sees: core
|
||||
* numbers first, then a release outranks its own prereleases, then the
|
||||
* prerelease identifiers left to right. Unparseable input compares as
|
||||
* equal so a junk version can never look like an update.
|
||||
*/
|
||||
export function compareAppVersions(left: string, right: string): number {
|
||||
const parsedLeft = parseAppVersion(left);
|
||||
const parsedRight = parseAppVersion(right);
|
||||
|
||||
if (!parsedLeft || !parsedRight) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const difference = parsedLeft.core[index] - parsedRight.core[index];
|
||||
|
||||
if (difference !== 0) {
|
||||
return Math.sign(difference);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedLeft.prerelease.length === 0) {
|
||||
return parsedRight.prerelease.length === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
if (parsedRight.prerelease.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const length = Math.max(
|
||||
parsedLeft.prerelease.length,
|
||||
parsedRight.prerelease.length
|
||||
);
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftIdentifier = parsedLeft.prerelease[index];
|
||||
const rightIdentifier = parsedRight.prerelease[index];
|
||||
|
||||
if (leftIdentifier === undefined) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (rightIdentifier === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const difference = compareIdentifiers(leftIdentifier, rightIdentifier);
|
||||
|
||||
if (difference !== 0) {
|
||||
return difference;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker the main process puts into the release-notes rejection when a
|
||||
* version has no published GitHub release. `ipcRenderer.invoke` strips every
|
||||
* custom property off a rejection and wraps the message, so the renderer can
|
||||
* only recognise the case by this text.
|
||||
*/
|
||||
export const APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER =
|
||||
'Release notes were not found for';
|
||||
|
||||
export function buildAppUpdateReleaseNotesNotFoundMessage(
|
||||
version: string | undefined
|
||||
): string {
|
||||
return `${APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER} ${version ?? 'latest release'}`;
|
||||
}
|
||||
|
||||
export function isAppUpdateReleaseNotesNotFoundMessage(
|
||||
message: string | null | undefined
|
||||
): boolean {
|
||||
return Boolean(
|
||||
message?.includes(APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER)
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AppUpdateChannel } from './app-update-channel.util';
|
||||
import type { SourceProbeContext, SourceHealthResult } from './source-health';
|
||||
import type { XtreamConnectionFailure } from './xtream-connection-test';
|
||||
import type { ZoomLevelAction } from './zoom-level.util';
|
||||
import type {
|
||||
CatchupDownloadMetadata,
|
||||
DownloadRecoveryResult,
|
||||
@@ -263,7 +265,20 @@ export interface ElectronBridgeAppUpdateStatus {
|
||||
progress?: ElectronBridgeAppUpdateProgress;
|
||||
error?: string;
|
||||
supportedSelfUpdate: boolean;
|
||||
/** Release page for the configured channel; opened for manual installs. */
|
||||
manualDownloadUrl: string;
|
||||
/** Channel the updater checks (`Settings.updateChannel`, mirrored). */
|
||||
channel: AppUpdateChannel;
|
||||
/**
|
||||
* Channel the current verdict — `latestVersion`, `release`, a download
|
||||
* in flight or finished — was produced on. Absent until the first
|
||||
* check. Differs from `channel` after a channel change saved while a
|
||||
* download was running or done: the download is kept, the new channel
|
||||
* has not been checked yet.
|
||||
*/
|
||||
verdictChannel?: AppUpdateChannel;
|
||||
/** Channel the running build came from, read off its version string. */
|
||||
installedChannel: AppUpdateChannel;
|
||||
}
|
||||
|
||||
export type ElectronBridgeAppUpdateReleaseNotesDirection = 'previous' | 'next';
|
||||
@@ -714,6 +729,19 @@ export interface ElectronBridgeApi {
|
||||
*/
|
||||
toggleFullScreenWindow: () => Promise<ElectronBridgeWindowState>;
|
||||
closeWindow: () => Promise<void>;
|
||||
/**
|
||||
* Zoom shortcuts (Cmd/Ctrl and +/−/0). Synchronous and preload-local:
|
||||
* steps the FRAME-BOUND temporary zoom level through `webFrame` (never a
|
||||
* main-process `webContents.setZoomLevel`, whose per-URL entry the app's
|
||||
* `file://` path routing resets — issue #1109) by `stepZoomLevel`'s
|
||||
* rules — one `ZOOM_LEVEL_STEP` inside `ZOOM_LEVEL_MIN..ZOOM_LEVEL_MAX`,
|
||||
* a stored level already outside them never moved against the request —
|
||||
* and returns the level now applied, which is therefore not itself
|
||||
* guaranteed to be within the limits. Persistence needs nothing from the
|
||||
* caller: the main process reads the live level back on close, quit and
|
||||
* reload.
|
||||
*/
|
||||
adjustZoomLevel: (action: ZoomLevelAction) => number;
|
||||
getWindowState: () => Promise<ElectronBridgeWindowState>;
|
||||
onWindowStateChange: (
|
||||
callback: (state: ElectronBridgeWindowState) => void
|
||||
@@ -848,6 +876,8 @@ export interface ElectronBridgeApi {
|
||||
url: string,
|
||||
options?: ElectronBridgeTrustOptions
|
||||
) => Promise<ElectronBridgeEpgFetchResult>;
|
||||
/** Native picker for a local XMLTV file; resolves null when cancelled. */
|
||||
openEpgFileDialog: () => Promise<string | null>;
|
||||
clearEpgData: () => Promise<ElectronBridgeResult>;
|
||||
reconcileEpgSources: (urls: string[]) => Promise<ElectronBridgeResult>;
|
||||
clearEpgDataForSource: (sourceUrl: string) => Promise<ElectronBridgeResult>;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
classifyEpgSourceReference,
|
||||
isLocalEpgSourceReference,
|
||||
isRemoteEpgSourceUrl,
|
||||
validateEpgSourceReferenceControl,
|
||||
} from './epg-source-reference.util';
|
||||
|
||||
describe('classifyEpgSourceReference', () => {
|
||||
it.each([
|
||||
'http://epg.example.org/guide.xml',
|
||||
'https://epg.example.org/guide.xml.gz?token=abc',
|
||||
' HTTPS://epg.example.org/guide.xml ',
|
||||
])('treats %s as remote', (value) => {
|
||||
expect(classifyEpgSourceReference(value)).toBe('remote');
|
||||
expect(isRemoteEpgSourceUrl(value)).toBe(true);
|
||||
expect(isLocalEpgSourceReference(value)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'file:///home/user/epg/guide.xml.gz',
|
||||
'file:///C:/epg/guide.xml',
|
||||
'/home/user/epg/guide.xml',
|
||||
'/Users/me/Downloads/guide.xml.gz',
|
||||
'C:\\epg\\guide.xml.gz',
|
||||
'd:/epg/guide.xml',
|
||||
'\\\\nas\\share\\epg\\guide.xml.gz',
|
||||
])('treats %s as a local file', (value) => {
|
||||
expect(classifyEpgSourceReference(value)).toBe('local');
|
||||
expect(isLocalEpgSourceReference(value)).toBe(true);
|
||||
expect(isRemoteEpgSourceUrl(value)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'guide.xml',
|
||||
'./guide.xml',
|
||||
'~/epg/guide.xml',
|
||||
'ftp://epg.example.org/guide.xml',
|
||||
'file://',
|
||||
'epg.example.org/guide.xml',
|
||||
'C:',
|
||||
'',
|
||||
' ',
|
||||
])('rejects %j', (value) => {
|
||||
expect(classifyEpgSourceReference(value)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects null and undefined', () => {
|
||||
expect(classifyEpgSourceReference(null)).toBeNull();
|
||||
expect(classifyEpgSourceReference(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEpgSourceReferenceControl', () => {
|
||||
it('accepts an empty control like Validators.pattern does', () => {
|
||||
expect(validateEpgSourceReferenceControl({ value: '' })).toBeNull();
|
||||
expect(validateEpgSourceReferenceControl({ value: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts remote links and local files', () => {
|
||||
expect(
|
||||
validateEpgSourceReferenceControl({
|
||||
value: 'https://epg.example.org/guide.xml',
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
validateEpgSourceReferenceControl({ value: '/tmp/guide.xml.gz' })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('flags relative paths and unsupported schemes', () => {
|
||||
expect(
|
||||
validateEpgSourceReferenceControl({ value: 'guide.xml' })
|
||||
).toEqual({ epgSourceReference: true });
|
||||
expect(
|
||||
validateEpgSourceReferenceControl({
|
||||
value: 'ftp://epg.example.org/guide.xml',
|
||||
})
|
||||
).toEqual({ epgSourceReference: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Classification of the value a user types as an EPG source.
|
||||
*
|
||||
* A source is either a remote XMLTV link (`http:`/`https:`) or a local file:
|
||||
* a `file:` URL, an absolute POSIX path, a Windows drive path or a UNC path.
|
||||
* Relative paths are refused on purpose — the main process has no meaningful
|
||||
* working directory to resolve them against.
|
||||
*
|
||||
* Only values the user entered by hand (Settings → EPG, the playlist dialog)
|
||||
* may be local. URLs harvested from an M3U header are filtered to remote ones
|
||||
* before they are stored, so a downloaded playlist can never point the EPG
|
||||
* importer at a file on the user's disk.
|
||||
*/
|
||||
export type EpgSourceReferenceKind = 'remote' | 'local';
|
||||
|
||||
export const EPG_SOURCE_REFERENCE_ERROR = 'epgSourceReference';
|
||||
|
||||
const REMOTE_EPG_SOURCE_PATTERN = /^https?:\/\/[^\s"]+$/i;
|
||||
const FILE_URL_EPG_SOURCE_PATTERN = /^file:\/\/[^\s"]+$/i;
|
||||
const POSIX_ABSOLUTE_PATH_PATTERN = /^\/[^\0]+$/;
|
||||
const WINDOWS_DRIVE_PATH_PATTERN = /^[a-z]:[\\/][^\0]*$/i;
|
||||
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\/\0]+\\[^\\/\0]+/;
|
||||
|
||||
export function classifyEpgSourceReference(
|
||||
value: string | null | undefined
|
||||
): EpgSourceReferenceKind | null {
|
||||
const trimmed = value?.trim() ?? '';
|
||||
if (trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
if (REMOTE_EPG_SOURCE_PATTERN.test(trimmed)) {
|
||||
return 'remote';
|
||||
}
|
||||
if (
|
||||
FILE_URL_EPG_SOURCE_PATTERN.test(trimmed) ||
|
||||
POSIX_ABSOLUTE_PATH_PATTERN.test(trimmed) ||
|
||||
WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) ||
|
||||
WINDOWS_UNC_PATH_PATTERN.test(trimmed)
|
||||
) {
|
||||
return 'local';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isRemoteEpgSourceUrl(
|
||||
value: string | null | undefined
|
||||
): boolean {
|
||||
return classifyEpgSourceReference(value) === 'remote';
|
||||
}
|
||||
|
||||
export function isLocalEpgSourceReference(
|
||||
value: string | null | undefined
|
||||
): boolean {
|
||||
return classifyEpgSourceReference(value) === 'local';
|
||||
}
|
||||
|
||||
/**
|
||||
* Form validator shared by the Settings EPG list and the playlist dialog.
|
||||
* Typed structurally so this contracts library stays Angular-free; an empty
|
||||
* control is valid, mirroring `Validators.pattern`.
|
||||
*/
|
||||
export function validateEpgSourceReferenceControl(control: {
|
||||
value: unknown;
|
||||
}): Record<typeof EPG_SOURCE_REFERENCE_ERROR, true> | null {
|
||||
const value = control.value;
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
return classifyEpgSourceReference(value)
|
||||
? null
|
||||
: { [EPG_SOURCE_REFERENCE_ERROR]: true };
|
||||
}
|
||||
@@ -140,6 +140,11 @@ export const WINDOW_TOGGLE_FULLSCREEN = 'WINDOW:TOGGLE_FULLSCREEN';
|
||||
export const WINDOW_CLOSE = 'WINDOW:CLOSE';
|
||||
export const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
|
||||
export const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
|
||||
// Synchronous preload request for the persisted app zoom level (answered
|
||||
// with `event.returnValue`), and the preload's acknowledgement once it has
|
||||
// applied that level with webFrame.setZoomLevel at DOMContentLoaded
|
||||
export const WINDOW_GET_ZOOM_LEVEL = 'WINDOW:GET_ZOOM_LEVEL';
|
||||
export const WINDOW_ZOOM_LEVEL_APPLIED = 'WINDOW:ZOOM_LEVEL_APPLIED';
|
||||
|
||||
// Close guard: while active, closing/quitting the app is intercepted in the
|
||||
// main process and handed to the renderer for a save/discard/stay decision
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { buildPlaylistRecentItems } from './playlist-recently-viewed.utils';
|
||||
import { resolvePortalActivityWatchKind } from './portal-activity-item.interface';
|
||||
import type { PlaylistMeta } from './playlist-meta.type';
|
||||
|
||||
const labels = { stalker: 'Stalker', m3u: 'M3U' };
|
||||
|
||||
function stalkerPlaylist(recentlyViewed: unknown[]): PlaylistMeta {
|
||||
return {
|
||||
_id: 'stalker-1',
|
||||
title: 'Portal',
|
||||
count: 0,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
recentlyViewed,
|
||||
} as unknown as PlaylistMeta;
|
||||
}
|
||||
|
||||
describe('buildPlaylistRecentItems (Stalker watch kind)', () => {
|
||||
it('keeps embedded-VOD shows routing as movies while tracking them as series', () => {
|
||||
// Real stored shape: no `is_series`, numeric VOD category, and the
|
||||
// `series[]` episode array the portal answered with.
|
||||
const [item] = buildPlaylistRecentItems(
|
||||
[
|
||||
stalkerPlaylist([
|
||||
{
|
||||
id: '17572',
|
||||
title: 'Fake (10 episodes)',
|
||||
category_id: '7',
|
||||
series: [1, 2, 3],
|
||||
added_at: '2026-09-19T16:13:50.000Z',
|
||||
},
|
||||
]),
|
||||
],
|
||||
labels
|
||||
);
|
||||
|
||||
expect(item.type).toBe('movie');
|
||||
expect(item.watch_kind).toBe('series');
|
||||
expect(resolvePortalActivityWatchKind(item)).toBe('series');
|
||||
});
|
||||
|
||||
it('marks lazy Ministra is_series rows as series-tracked too', () => {
|
||||
const [item] = buildPlaylistRecentItems(
|
||||
[
|
||||
stalkerPlaylist([
|
||||
{
|
||||
id: '50001',
|
||||
title: 'Flagged',
|
||||
category_id: 'vod',
|
||||
is_series: '1',
|
||||
added_at: '2026-09-19T16:13:50.000Z',
|
||||
},
|
||||
]),
|
||||
],
|
||||
labels
|
||||
);
|
||||
|
||||
expect(item.type).toBe('series');
|
||||
expect(item.watch_kind).toBe('series');
|
||||
});
|
||||
|
||||
it('leaves plain movies and live channels without a watch kind override', () => {
|
||||
const items = buildPlaylistRecentItems(
|
||||
[
|
||||
stalkerPlaylist([
|
||||
{
|
||||
id: '900',
|
||||
title: 'A Movie',
|
||||
category_id: '7',
|
||||
series: [],
|
||||
added_at: '2026-09-19T16:13:50.000Z',
|
||||
},
|
||||
{
|
||||
id: '712',
|
||||
title: 'Channel',
|
||||
category_id: 'itv',
|
||||
added_at: '2026-09-19T14:49:22.000Z',
|
||||
},
|
||||
]),
|
||||
],
|
||||
labels
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.watch_kind)).toEqual([
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
expect(resolvePortalActivityWatchKind(items[0])).toBe('movie');
|
||||
expect(resolvePortalActivityWatchKind(items[1])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
extractStalkerItemPoster,
|
||||
extractStalkerItemTitle,
|
||||
extractStalkerItemType,
|
||||
isStalkerSeriesItem,
|
||||
normalizeStalkerDate,
|
||||
} from './stalker-item.normalizer';
|
||||
import {
|
||||
@@ -59,6 +60,10 @@ function mapStalkerPlaylistRecentItems(
|
||||
backdrop_url: extractStalkerItemTmdbHints(item).backdropUrl,
|
||||
source: 'stalker',
|
||||
stalker_item: rawItem,
|
||||
// `type` stays the routing verdict: an embedded-VOD row
|
||||
// (a `series[]` array, no flag) routes as a movie. Either
|
||||
// shape tracks progress per episode, keyed by this parent id.
|
||||
...(isStalkerSeriesItem(item) ? { watch_kind: 'series' } : {}),
|
||||
});
|
||||
|
||||
return acc;
|
||||
|
||||
@@ -47,6 +47,43 @@ export interface PortalActivityItem {
|
||||
source?: 'xtream' | 'stalker' | 'm3u';
|
||||
/** Original stalker item for navigation state; undefined for xtream. */
|
||||
stalker_item?: StalkerPortalItem;
|
||||
/**
|
||||
* How the item's watch progress is tracked, when that differs from the
|
||||
* catalog section `type` routes it to.
|
||||
*
|
||||
* The case that needs it is a Stalker embedded-VOD row: its stored entry
|
||||
* announces episodes through a `series[]` array, but `extractStalkerItemType`
|
||||
* is deliberately blind to that array so the item keeps routing to the VOD
|
||||
* catalog, and it therefore reports `type: 'movie'` while its progress is a
|
||||
* set of EPISODE positions keyed by the parent id. (A lazy Ministra
|
||||
* `is_series` row already reports `series` — the flag is read — so there
|
||||
* this field only restates the type.)
|
||||
*
|
||||
* Readers that decide between a movie position and a series position go
|
||||
* through `resolvePortalActivityWatchKind`, never `type` alone. Absent
|
||||
* means "same as `type`".
|
||||
*/
|
||||
watch_kind?: PortalActivityWatchKind;
|
||||
}
|
||||
|
||||
/** Progress model of a VOD activity item: one position, or per-episode positions. */
|
||||
export type PortalActivityWatchKind = 'movie' | 'series';
|
||||
|
||||
/**
|
||||
* The progress model a dashboard/collection reader should use for an item:
|
||||
* the explicit `watch_kind` when a mapper recorded one, else the catalog
|
||||
* `type`. Live items have no progress and resolve to `null`.
|
||||
*/
|
||||
export function resolvePortalActivityWatchKind(
|
||||
item: Pick<PortalActivityItem, 'type' | 'watch_kind'>
|
||||
): PortalActivityWatchKind | null {
|
||||
if (item.watch_kind === 'movie' || item.watch_kind === 'series') {
|
||||
return item.watch_kind;
|
||||
}
|
||||
if (item.type === 'movie' || item.type === 'series') {
|
||||
return item.type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A recently-viewed item with a `viewed_at` timestamp. */
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
RENDERER_RESTORE_ROUTE_QUERY_PARAM,
|
||||
resolveRestoredRendererRoute,
|
||||
} from './renderer-reload-route.util';
|
||||
|
||||
const rendererRoot = 'file:///Applications/IPTVnator.app/Contents/web/';
|
||||
const packagedIndex = `${rendererRoot}index.html`;
|
||||
|
||||
function reloadedIndex(route: string): string {
|
||||
const url = new URL(packagedIndex);
|
||||
url.searchParams.set(RENDERER_RESTORE_ROUTE_QUERY_PARAM, route);
|
||||
return url.href;
|
||||
}
|
||||
|
||||
describe('resolveRestoredRendererRoute', () => {
|
||||
it('leaves a document without a restore request alone', () => {
|
||||
expect(resolveRestoredRendererRoute(packagedIndex, packagedIndex)).toBe(
|
||||
null
|
||||
);
|
||||
expect(
|
||||
resolveRestoredRendererRoute(
|
||||
`${packagedIndex}?other=1`,
|
||||
packagedIndex
|
||||
)
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it('restores a route relative to the renderer directory', () => {
|
||||
expect(
|
||||
resolveRestoredRendererRoute(
|
||||
reloadedIndex('workspace/sources'),
|
||||
packagedIndex
|
||||
)
|
||||
).toBe(`${rendererRoot}workspace/sources`);
|
||||
});
|
||||
|
||||
it('keeps the restored route query and fragment', () => {
|
||||
expect(
|
||||
resolveRestoredRendererRoute(
|
||||
reloadedIndex('workspace/xtreams/3/search?q=dune#top'),
|
||||
packagedIndex
|
||||
)
|
||||
).toBe(`${rendererRoot}workspace/xtreams/3/search?q=dune#top`);
|
||||
});
|
||||
|
||||
it('resolves against the base URI, not the document URL', () => {
|
||||
// The packaged <base href="./"> resolves to the renderer directory
|
||||
// even when the document itself sits deeper.
|
||||
expect(
|
||||
resolveRestoredRendererRoute(
|
||||
`${rendererRoot}workspace/sources?${RENDERER_RESTORE_ROUTE_QUERY_PARAM}=workspace%2Fdashboard`,
|
||||
rendererRoot
|
||||
)
|
||||
).toBe(`${rendererRoot}workspace/dashboard`);
|
||||
});
|
||||
|
||||
it('works for an http origin with a root base href', () => {
|
||||
expect(
|
||||
resolveRestoredRendererRoute(
|
||||
`http://localhost:4200/?${RENDERER_RESTORE_ROUTE_QUERY_PARAM}=workspace%2Fsettings%2Fplayback`,
|
||||
'http://localhost:4200/'
|
||||
)
|
||||
).toBe('http://localhost:4200/workspace/settings/playback');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an absolute URL', 'https://example.com/phish'],
|
||||
['another scheme', 'javascript:alert(1)'],
|
||||
['a directory escape', '../../etc/passwd'],
|
||||
['a root-absolute path outside the renderer', '/etc/passwd'],
|
||||
['a scheme-relative URL', '//example.com/'],
|
||||
['the renderer directory itself', './'],
|
||||
['an empty route', ''],
|
||||
])('drops %s and only removes the parameter', (_label, route) => {
|
||||
expect(
|
||||
resolveRestoredRendererRoute(reloadedIndex(route), packagedIndex)
|
||||
).toBe(packagedIndex);
|
||||
});
|
||||
|
||||
it('returns null for an unparsable document URL', () => {
|
||||
expect(resolveRestoredRendererRoute('not a url', packagedIndex)).toBe(
|
||||
null
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Route hand-off for a reloaded packaged renderer.
|
||||
*
|
||||
* The packaged renderer is loaded from `dist/apps/web/index.html` over
|
||||
* `file://` and Angular uses PATH routing, so after in-app navigation the
|
||||
* document URL is `file:///…/web/workspace/sources` — a path with no file
|
||||
* behind it. A reload (the macOS View › Reload menu, DevTools, the settings
|
||||
* unsaved-changes guard's confirmed reload) therefore fails with
|
||||
* `ERR_FILE_NOT_FOUND` and strands the window on Chromium's error page.
|
||||
*
|
||||
* The Electron main process recovers such a failure by loading `index.html`
|
||||
* again with the failed URL's route (its path relative to the renderer root,
|
||||
* plus query and fragment) carried in this query parameter. The renderer
|
||||
* consumes it before Angular bootstraps: `resolveRestoredRendererRoute`
|
||||
* turns the current document URL into the in-app URL the router should
|
||||
* start from, and `main.ts` installs it with `history.replaceState`, so the
|
||||
* router's initial navigation lands on the route the user was on.
|
||||
*/
|
||||
|
||||
/** Query parameter carrying the route to restore on the reloaded index. */
|
||||
export const RENDERER_RESTORE_ROUTE_QUERY_PARAM = 'restoreRoute';
|
||||
|
||||
/**
|
||||
* The URL to present instead of `currentHref` before the router's initial
|
||||
* navigation, or `null` when the document carries no restore request.
|
||||
*
|
||||
* `baseUri` is `document.baseURI`: the packaged build's `<base href="./">`
|
||||
* resolves to the renderer's directory, which is also what Angular strips
|
||||
* from `location.pathname` to obtain the route. A route that would leave
|
||||
* that directory — an absolute URL, another scheme, a `..` escape — is
|
||||
* dropped and only the parameter is removed, so the app boots at its
|
||||
* default route rather than following an arbitrary target.
|
||||
*/
|
||||
export function resolveRestoredRendererRoute(
|
||||
currentHref: string,
|
||||
baseUri: string
|
||||
): string | null {
|
||||
let current: URL;
|
||||
let baseDirectory: URL;
|
||||
|
||||
try {
|
||||
current = new URL(currentHref);
|
||||
baseDirectory = new URL('./', baseUri);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const route = current.searchParams.get(RENDERER_RESTORE_ROUTE_QUERY_PARAM);
|
||||
|
||||
if (route === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
current.searchParams.delete(RENDERER_RESTORE_ROUTE_QUERY_PARAM);
|
||||
const stripped = current.href;
|
||||
|
||||
let target: URL;
|
||||
|
||||
try {
|
||||
target = new URL(route, baseDirectory);
|
||||
} catch {
|
||||
return stripped;
|
||||
}
|
||||
|
||||
const staysInsideRenderer =
|
||||
target.protocol === baseDirectory.protocol &&
|
||||
target.host === baseDirectory.host &&
|
||||
target.pathname.startsWith(baseDirectory.pathname) &&
|
||||
target.pathname !== baseDirectory.pathname;
|
||||
|
||||
return staysInsideRenderer ? target.href : stripped;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { foldSearchText } from './search-text-fold.util';
|
||||
|
||||
describe('foldSearchText', () => {
|
||||
it('folds the Turkish dotted capital I onto a plain lower-case i (issue #609)', () => {
|
||||
expect(foldSearchText('İnşaat Kanalı')).toBe('inşaat kanalı');
|
||||
expect(
|
||||
foldSearchText('İnşaat Kanalı').includes(foldSearchText('inş'))
|
||||
).toBe(true);
|
||||
expect(foldSearchText('İnş')).toBe(foldSearchText('inş'));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Ünlü Şef', 'ünlü şef'],
|
||||
['ÇANAKKALE', 'çanakkale'],
|
||||
['Первый канал HD', 'первый канал hd'],
|
||||
['Ёжик', 'ёжик'],
|
||||
['Йога для всех', 'йога для всех'],
|
||||
['Ελλάδα Σήμερα', 'ελλάδα σήμερα'],
|
||||
['Amélie', 'amélie'],
|
||||
['Straße', 'straße'],
|
||||
])(
|
||||
'keeps precomposed letters of other scripts intact: %s',
|
||||
(input, expected) => {
|
||||
expect(foldSearchText(input)).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['Amélie', 'Amélie'],
|
||||
['İnşaat', 'İnşaat'],
|
||||
['Ёжик', 'Ёжик'],
|
||||
['Йога', 'Йога'],
|
||||
['Ünlü', 'Ünlü'],
|
||||
['Ά', 'Ά'],
|
||||
])(
|
||||
'folds canonically equivalent spellings of %s to one string',
|
||||
(precomposed, decomposed) => {
|
||||
expect(foldSearchText(decomposed)).toBe(
|
||||
foldSearchText(precomposed)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('stays accent-sensitive, unlike the diacritic-folding SQL index', () => {
|
||||
// The renderer filters were accent-sensitive before this helper and
|
||||
// stay so: only marks that cannot compose are dropped. Accent-blind
|
||||
// matching is the FTS index's own behaviour, not this fold's.
|
||||
expect(foldSearchText('Amélie')).not.toBe(foldSearchText('Amelie'));
|
||||
});
|
||||
|
||||
it('does not fold the dotless ı onto i', () => {
|
||||
// The Turkish I/ı pair is a separate letter, not a case form of i;
|
||||
// it is deliberately left alone (see the FTS index, which does not
|
||||
// fold it either).
|
||||
expect(foldSearchText('Işık')).toBe('işık');
|
||||
expect(foldSearchText('ışık')).toBe('ışık');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Locale-invariant case fold for in-memory search filters (channel lists,
|
||||
* catalog and category filters, source and download lists). Pure function,
|
||||
* no Angular/Node dependencies.
|
||||
*
|
||||
* `toLowerCase()` alone is not enough for one visible input: the Turkish
|
||||
* dotted capital "İ" (U+0130) lower-cases to "i" plus a combining dot above
|
||||
* (U+0307), so `"İnşaat".toLowerCase().includes("inş")` is `false` even
|
||||
* though the two spellings are the same word (issue #609).
|
||||
*
|
||||
* The fold therefore runs `toLowerCase()`, re-composes the result to NFC and
|
||||
* drops whatever combining marks are left. Composing first is what makes the
|
||||
* fold agree on canonically equivalent text: a provider title stored
|
||||
* decomposed ("e" + U+0301) and a query typed precomposed ("é") reach the
|
||||
* same string, and a Greek "Ά" lower-cases to a form NFC maps onto the same
|
||||
* "ά" the user types. Only marks that cannot compose survive to be stripped —
|
||||
* the dotted I's leftover dot among them — so precomposed letters (ç, ş, ü,
|
||||
* é, ё, й) are preserved and the fold stays accent-sensitive, exactly as the
|
||||
* filters were before. A lower-cased string of printable ASCII is already NFC
|
||||
* and carries no marks, which is the fast path taken by most titles.
|
||||
*
|
||||
* `toLocaleLowerCase()` is deliberately not used: under a Turkish or Azeri
|
||||
* OS locale it maps ASCII "I" to the dotless "ı", so the same list would
|
||||
* filter differently per machine.
|
||||
*/
|
||||
const COMBINING_MARKS_REGEXP = /[̀-ͯ]/g;
|
||||
const NON_PRINTABLE_ASCII_REGEXP = /[^ -~]/;
|
||||
|
||||
export function foldSearchText(value: string): string {
|
||||
const lowered = value.toLowerCase();
|
||||
|
||||
return NON_PRINTABLE_ASCII_REGEXP.test(lowered)
|
||||
? lowered.normalize('NFC').replace(COMBINING_MARKS_REGEXP, '')
|
||||
: lowered;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AppUpdateChannel } from './app-update-channel.util';
|
||||
import { Language } from './language.enum';
|
||||
import { StreamFormat } from './stream-format.enum';
|
||||
import { Theme } from './theme.enum';
|
||||
@@ -189,6 +190,15 @@ export interface Settings {
|
||||
* value means `'normal'`. F11 leaves or re-enters fullscreen.
|
||||
*/
|
||||
startupWindowMode?: StartupWindowMode;
|
||||
/**
|
||||
* Desktop update channel: `stable` (tagged releases) or `nightly`
|
||||
* (prereleases published from every master merge). Electron only;
|
||||
* a missing value means `stable`. Mirrored into the main-process config
|
||||
* on Save so the startup update check already uses it. Forward-only:
|
||||
* switching back to stable keeps the installed nightly until a newer
|
||||
* stable release exists.
|
||||
*/
|
||||
updateChannel?: AppUpdateChannel;
|
||||
/** Show the desktop footer bar for external playback status */
|
||||
showExternalPlaybackBar?: boolean;
|
||||
/** Strip country/group prefixes like "US | " or "UK - " from channel names */
|
||||
@@ -241,6 +251,15 @@ export interface Settings {
|
||||
portalConnectivityGuard?: boolean;
|
||||
/** Cover/poster sizing preset applied across grids and rails */
|
||||
coverSize?: CoverSize;
|
||||
/**
|
||||
* Titles under movie/series covers. Default on; only an explicit false
|
||||
* switches VOD, series, favorites and recent grids to a posters-only
|
||||
* wall whose title appears as an overlay on hover/focus. Live channel
|
||||
* grids, search results and the dashboard rails always keep their
|
||||
* labels: logos are often missing, a search answers by name, and rails
|
||||
* carry meta rows the overlay cannot hold.
|
||||
*/
|
||||
showCoverTitles?: boolean;
|
||||
/** Live EPG panel layout: horizontal timeline (default) or vertical list */
|
||||
epgViewMode?: EpgViewMode;
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
cleanTitleForSearch,
|
||||
normalizeTitle,
|
||||
normalizeTitleKeys,
|
||||
titleYearsCompatible,
|
||||
@@ -429,3 +430,81 @@ describe('titleYearsCompatible', () => {
|
||||
expect(titleYearsCompatible(1982, 2049)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanTitleForSearch', () => {
|
||||
it('keeps Cyrillic letters that folding would rewrite', () => {
|
||||
// NFD splits "й" into "и" + a breve and "ё" into "е" + a diaeresis;
|
||||
// the comparison key drops both marks, and TMDB answers that folded
|
||||
// spelling with nothing. The titles here are illustrative stand-ins
|
||||
// chosen to fold the same way.
|
||||
expect(normalizeTitle('Лейка (10 серий)')).toBe('леика');
|
||||
expect(cleanTitleForSearch('Лейка (10 серий)')).toBe('Лейка');
|
||||
expect(cleanTitleForSearch('Ёжик 2010')).toBe('Ёжик');
|
||||
expect(cleanTitleForSearch('Тестовый Сериал s02')).toBe(
|
||||
'Тестовый Сериал'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps Arabic hamza forms that folding splits into two words', () => {
|
||||
// "أ" decomposes into a bare alef + U+0654, which is outside the
|
||||
// stripped mark range and so becomes a SPACE in the key — splitting
|
||||
// the word in two, which is not a spelling TMDB indexes.
|
||||
expect(normalizeTitle('AR| أمثلة تجريبية')).toBe('ا مثلة تجريبية');
|
||||
expect(cleanTitleForSearch('AR| أمثلة تجريبية')).toBe('أمثلة تجريبية');
|
||||
});
|
||||
|
||||
it('keeps Latin diacritics and casing', () => {
|
||||
expect(cleanTitleForSearch('Amélie (2001)')).toBe('Amélie');
|
||||
expect(cleanTitleForSearch('THE LAST OF US')).toBe('THE LAST OF US');
|
||||
});
|
||||
|
||||
it('recomposes decomposed input and keeps marks attached to their letter', () => {
|
||||
// Providers ship "Dönüş" as "o" + U+0308; the key folds it away but
|
||||
// the query must not split the word on the combining mark.
|
||||
expect(
|
||||
cleanTitleForSearch(
|
||||
'1942\u2032ye Do\u0308nu\u0308s\u0327 (2012) TR'
|
||||
)
|
||||
).toBe('1942 ye Dönüş');
|
||||
expect(
|
||||
normalizeTitle('1942\u2032ye Do\u0308nu\u0308s\u0327 (2012) TR')
|
||||
).toBe('1942 ye donus');
|
||||
// No precomposed form exists for "ọ̀": the mark stays on the letter
|
||||
expect(cleanTitleForSearch('AR - Ìjọ̀gbọ̀n (2023)')).toBe('Ìjọ̀gbọ̀n');
|
||||
expect(normalizeTitle('AR - Ìjọ̀gbọ̀n (2023)')).toBe('ijogbon');
|
||||
});
|
||||
|
||||
it('applies the same tag, bracket, season and year stripping as the key', () => {
|
||||
expect(cleanTitleForSearch('EN - The Matrix (1999) 4K')).toBe(
|
||||
'The Matrix'
|
||||
);
|
||||
expect(cleanTitleForSearch('|DE| Breaking Bad-DE')).toBe(
|
||||
'Breaking Bad'
|
||||
);
|
||||
expect(cleanTitleForSearch('The Boys s05')).toBe('The Boys');
|
||||
expect(cleanTitleForSearch('Пацаны 2 сезон')).toBe('Пацаны');
|
||||
expect(cleanTitleForSearch('The Matrix 1999')).toBe('The Matrix');
|
||||
// A leading token that is the film's own name survives on both tiers
|
||||
expect(cleanTitleForSearch('AKA - 2023')).toBe('AKA');
|
||||
expect(normalizeTitle('AKA - 2023')).toBe('aka');
|
||||
});
|
||||
|
||||
it('normalizes to the same key as the text it was derived from', () => {
|
||||
for (const raw of [
|
||||
'Лейка (10 серий)',
|
||||
'EN - The Matrix (1999) 4K',
|
||||
'Ёжик 2010',
|
||||
'Amélie (2001)',
|
||||
'|FR|VO|Le dernier empereur',
|
||||
]) {
|
||||
expect(normalizeTitle(cleanTitleForSearch(raw))).toBe(
|
||||
normalizeTitle(raw)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns an empty string for empty input', () => {
|
||||
expect(cleanTitleForSearch('')).toBe('');
|
||||
expect(cleanTitleForSearch(null)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -302,13 +302,13 @@ const HAS_LETTER = /\p{L}/u;
|
||||
* "2023" and were offered to each other as alternative sources. A miss beats
|
||||
* a wrong match, so an unknown token keeps its title.
|
||||
*/
|
||||
function normalizeAfterLeadingTag(value: string): string {
|
||||
function normalizeAfterLeadingTag(value: string, fold: boolean): string {
|
||||
const match = value.match(LANGUAGE_PREFIX);
|
||||
if (!match) {
|
||||
return normalizeRest(value);
|
||||
return normalizeRest(value, fold);
|
||||
}
|
||||
|
||||
const stripped = normalizeRest(value.replace(LANGUAGE_PREFIX, ''));
|
||||
const stripped = normalizeRest(value.replace(LANGUAGE_PREFIX, ''), fold);
|
||||
// Deliberately not `stripSeason`: its "never return empty" fallback would
|
||||
// report a lone season marker as a surviving word.
|
||||
if (HAS_LETTER.test(stripped.replace(SEASON_SUFFIX_PATTERN, ''))) {
|
||||
@@ -316,7 +316,7 @@ function normalizeAfterLeadingTag(value: string): string {
|
||||
}
|
||||
|
||||
const token = match[0].replace(PREFIX_SEPARATOR_TAIL, '');
|
||||
return isKnownPrefixTag(token) ? stripped : normalizeRest(value);
|
||||
return isKnownPrefixTag(token) ? stripped : normalizeRest(value, fold);
|
||||
}
|
||||
|
||||
const DOUBLE_DASH_SUFFIX = /[-–]{2}[A-Za-z]{2,5}\s*$/;
|
||||
@@ -438,28 +438,49 @@ const SEASON_SUFFIX_PATTERN = new RegExp(
|
||||
* actually produce instead of predicting it. Cheap enough to run twice,
|
||||
* because the second run only happens for a title whose stripped form came
|
||||
* out with no word in it at all.
|
||||
*
|
||||
* `fold` selects the letter folding (diacritics, case, sigma). It is ON for
|
||||
* every comparison key and OFF for the text sent to a remote search — see
|
||||
* `cleanTitleForSearch`. Folding is lossy for scripts whose "diacritics" are
|
||||
* distinct letters: NFD turns Cyrillic "й" into "и" + a combining breve, and
|
||||
* dropping the breve rewrites "Лейка" as "леика", "ё" as "е". Two provider
|
||||
* copies of a title still meet on that key, which is all a comparison needs,
|
||||
* but TMDB's search does not fold Cyrillic the same way and answers a folded
|
||||
* query with nothing at all.
|
||||
*/
|
||||
function normalizeRest(value: string): string {
|
||||
return (
|
||||
stripTrailingTags(value)
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
// Greek Σ has two lowercase forms and `toLowerCase` picks by
|
||||
// position: "ΑΣ" becomes "ας" while an already-lowercase "ασ"
|
||||
// stays medial, so the same word reaches this line spelled two
|
||||
// ways. Both SQL tiers fold them together — SQLite's trigram
|
||||
// tokenizer does it natively, and the scan's GLOB classes do it in
|
||||
// `caseInsensitiveGlobPattern` — so without this the candidate is
|
||||
// admitted by the query and then thrown away by the confirmation.
|
||||
// Folding to the medial form is what Unicode case folding does.
|
||||
.replace(/ς/g, 'σ')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.split(' ')
|
||||
.filter((token) => token !== '' && !QUALITY_TAGS.has(token))
|
||||
.join(' ')
|
||||
.trim()
|
||||
);
|
||||
function normalizeRest(value: string, fold: boolean): string {
|
||||
const withoutTrailingTags = stripTrailingTags(value);
|
||||
const folded = fold
|
||||
? withoutTrailingTags
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
// Greek Σ has two lowercase forms and `toLowerCase` picks by
|
||||
// position: "ΑΣ" becomes "ας" while an already-lowercase "ασ"
|
||||
// stays medial, so the same word reaches this line spelled two
|
||||
// ways. Both SQL tiers fold them together — SQLite's trigram
|
||||
// tokenizer does it natively, and the scan's GLOB classes do
|
||||
// it in `caseInsensitiveGlobPattern` — so without this the
|
||||
// candidate is admitted by the query and then thrown away by
|
||||
// the confirmation. Folding to the medial form is what Unicode
|
||||
// case folding does.
|
||||
.replace(/ς/g, 'σ')
|
||||
: // Providers ship some titles decomposed ("o" + U+0308 for "ö").
|
||||
// Recompose so the query reads as the provider meant it, and
|
||||
// keep any mark that has no precomposed form ("ọ̀") attached to
|
||||
// its letter instead of letting the word-splitting step below
|
||||
// turn it into a space inside the word.
|
||||
withoutTrailingTags.normalize('NFC');
|
||||
const nonWord = fold ? /[^\p{L}\p{N}]+/gu : /[^\p{L}\p{N}\p{M}]+/gu;
|
||||
|
||||
return folded
|
||||
.replace(nonWord, ' ')
|
||||
.split(' ')
|
||||
.filter(
|
||||
(token) => token !== '' && !QUALITY_TAGS.has(token.toLowerCase())
|
||||
)
|
||||
.join(' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -488,6 +509,28 @@ export interface NormalizedTitleKeys {
|
||||
|
||||
export function normalizeTitleKeys(
|
||||
raw: string | null | undefined
|
||||
): NormalizedTitleKeys {
|
||||
return buildTitleKeys(raw, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The title to SEND to a remote search such as TMDB: the same tag, bracket,
|
||||
* season and year stripping as `normalizeTitle`, but with the letters left
|
||||
* exactly as the provider wrote them — no diacritic folding, no lowercasing.
|
||||
*
|
||||
* Comparison keys must fold so two spellings of one film meet; a search
|
||||
* query must not, because the search engine folds by its own rules and a
|
||||
* pre-folded Cyrillic query matches nothing there ("леика" for "Лейка"
|
||||
* illustrates the rewrite).
|
||||
* Compare the results with `normalizeTitle`, never with this.
|
||||
*/
|
||||
export function cleanTitleForSearch(raw: string | null | undefined): string {
|
||||
return buildTitleKeys(raw, false).base;
|
||||
}
|
||||
|
||||
function buildTitleKeys(
|
||||
raw: string | null | undefined,
|
||||
fold: boolean
|
||||
): NormalizedTitleKeys {
|
||||
if (!raw) {
|
||||
return { exact: '', base: '', trailingYear: null };
|
||||
@@ -498,7 +541,8 @@ export function normalizeTitleKeys(
|
||||
.replace(WRAPPED_TAG_PREFIX, '')
|
||||
// Inner classes exclude the opening delimiter too, so runaway
|
||||
// inputs like "[[[[[..." backtrack linearly (CodeQL js/polynomial-redos)
|
||||
.replace(/\[[^\][]*\]|\([^()]*\)|\{[^{}]*\}/g, ' ')
|
||||
.replace(/\[[^\][]*\]|\([^()]*\)|\{[^{}]*\}/g, ' '),
|
||||
fold
|
||||
);
|
||||
|
||||
const exact = stripSeason(cleaned);
|
||||
|
||||
@@ -17,6 +17,13 @@ export interface XtreamSerieDetails {
|
||||
* the provider's `seasons[].overview` is empty or URL-only junk.
|
||||
*/
|
||||
tmdb_season_overviews?: Record<string, string>;
|
||||
/**
|
||||
* Populated by lazy TMDB season enrichment; absent in raw provider
|
||||
* responses. Keyed like `tmdb_season_overviews`. Full image URLs of
|
||||
* each season's own TMDB poster, the first choice for the season
|
||||
* cover in the detail view and the fullscreen episode panel.
|
||||
*/
|
||||
tmdb_season_posters?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface XtreamSerieInfo {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
clampZoomLevel,
|
||||
stepZoomLevel,
|
||||
ZOOM_LEVEL_MAX,
|
||||
ZOOM_LEVEL_MIN,
|
||||
ZOOM_LEVEL_STEP,
|
||||
} from './zoom-level.util';
|
||||
|
||||
describe('zoom level shortcuts contract', () => {
|
||||
it('steps by the Electron menu-role step in both directions', () => {
|
||||
expect(stepZoomLevel(0, 'in')).toBe(ZOOM_LEVEL_STEP);
|
||||
expect(stepZoomLevel(0, 'out')).toBe(-ZOOM_LEVEL_STEP);
|
||||
expect(stepZoomLevel(1, 'in')).toBe(1.5);
|
||||
expect(stepZoomLevel(-1.5, 'out')).toBe(-2);
|
||||
});
|
||||
|
||||
it('resets to the default level from anywhere', () => {
|
||||
expect(stepZoomLevel(3, 'reset')).toBe(0);
|
||||
expect(stepZoomLevel(-2.5, 'reset')).toBe(0);
|
||||
expect(stepZoomLevel(Number.NaN, 'reset')).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps at the app limits, not at Chromium limits', () => {
|
||||
expect(stepZoomLevel(ZOOM_LEVEL_MAX, 'in')).toBe(ZOOM_LEVEL_MAX);
|
||||
expect(stepZoomLevel(ZOOM_LEVEL_MIN, 'out')).toBe(ZOOM_LEVEL_MIN);
|
||||
expect(stepZoomLevel(ZOOM_LEVEL_MAX - 0.5, 'in')).toBe(ZOOM_LEVEL_MAX);
|
||||
expect(clampZoomLevel(ZOOM_LEVEL_MIN - 1)).toBe(ZOOM_LEVEL_MIN);
|
||||
});
|
||||
|
||||
it('never moves a stored out-of-range level against the request', () => {
|
||||
// The macOS menu roles never clamped and the store restores any
|
||||
// finite level, so an upgrade can start beyond the limits.
|
||||
expect(stepZoomLevel(ZOOM_LEVEL_MAX + 1, 'in')).toBe(
|
||||
ZOOM_LEVEL_MAX + 1
|
||||
);
|
||||
expect(stepZoomLevel(ZOOM_LEVEL_MAX + 1, 'out')).toBe(ZOOM_LEVEL_MAX);
|
||||
expect(stepZoomLevel(ZOOM_LEVEL_MAX + 0.3, 'out')).toBe(ZOOM_LEVEL_MAX);
|
||||
expect(stepZoomLevel(ZOOM_LEVEL_MIN - 1, 'out')).toBe(
|
||||
ZOOM_LEVEL_MIN - 1
|
||||
);
|
||||
expect(stepZoomLevel(ZOOM_LEVEL_MIN - 1, 'in')).toBe(ZOOM_LEVEL_MIN);
|
||||
expect(stepZoomLevel(ZOOM_LEVEL_MIN - 1, 'reset')).toBe(0);
|
||||
});
|
||||
|
||||
it('snaps an off-grid level to the next grid point in the requested direction', () => {
|
||||
// A level the macOS menu roles or an older store left between steps.
|
||||
expect(stepZoomLevel(0.3, 'in')).toBe(0.5);
|
||||
expect(stepZoomLevel(0.3, 'out')).toBe(0);
|
||||
expect(stepZoomLevel(-0.7, 'out')).toBe(-1);
|
||||
expect(stepZoomLevel(-0.7, 'in')).toBe(-0.5);
|
||||
});
|
||||
|
||||
it('treats a non-finite level as the default', () => {
|
||||
expect(stepZoomLevel(Number.NaN, 'in')).toBe(ZOOM_LEVEL_STEP);
|
||||
expect(stepZoomLevel(Number.POSITIVE_INFINITY, 'out')).toBe(
|
||||
-ZOOM_LEVEL_STEP
|
||||
);
|
||||
expect(clampZoomLevel(Number.NaN)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* App zoom shortcut contract (Cmd/Ctrl and +/−/0), shared by the renderer
|
||||
* key binding (`WorkspaceKeyboardShortcutsService`) and the preload, which
|
||||
* applies the result through `webFrame.setZoomLevel` (issue #1109: the level
|
||||
* must stay a frame-bound temporary zoom; see
|
||||
* `docs/architecture/workspace-shell.md`, "Zoom level").
|
||||
*
|
||||
* A Chromium zoom level is the exponent of a 1.2 factor: level 1 renders at
|
||||
* 120 %, level −1 at ~83 %. The step matches Electron's `zoomIn`/`zoomOut`
|
||||
* menu roles (0.5, about 10 % per press), so the level the macOS menu used
|
||||
* to produce and the level the shortcuts produce are the same series.
|
||||
*/
|
||||
|
||||
export type ZoomLevelAction = 'in' | 'out' | 'reset';
|
||||
|
||||
export const ZOOM_LEVEL_DEFAULT = 0;
|
||||
/** Electron's `zoomIn`/`zoomOut` role step. */
|
||||
export const ZOOM_LEVEL_STEP = 0.5;
|
||||
/** ≈48 % — below this the workspace chrome is unreadable. */
|
||||
export const ZOOM_LEVEL_MIN = -4;
|
||||
/** ≈299 % — well inside Chromium's own 25–500 % limits. */
|
||||
export const ZOOM_LEVEL_MAX = 6;
|
||||
|
||||
export function clampZoomLevel(level: number): number {
|
||||
if (!Number.isFinite(level)) {
|
||||
return ZOOM_LEVEL_DEFAULT;
|
||||
}
|
||||
|
||||
return Math.min(ZOOM_LEVEL_MAX, Math.max(ZOOM_LEVEL_MIN, level));
|
||||
}
|
||||
|
||||
/**
|
||||
* The level `action` produces from `current`. Steps are snapped to the step
|
||||
* grid so a level restored from an older store (or set by the macOS menu
|
||||
* before the shortcuts existed) does not carry a fractional offset forever.
|
||||
* A level already outside the limits — the macOS menu roles never clamped,
|
||||
* and the store restores any finite level — is never moved AGAINST the
|
||||
* request: a press further out leaves it where it is, a press back in lands
|
||||
* on the limit. A non-finite `current` is treated as the default.
|
||||
*/
|
||||
export function stepZoomLevel(
|
||||
current: number,
|
||||
action: ZoomLevelAction
|
||||
): number {
|
||||
if (action === 'reset') {
|
||||
return ZOOM_LEVEL_DEFAULT;
|
||||
}
|
||||
|
||||
const base = Number.isFinite(current) ? current : ZOOM_LEVEL_DEFAULT;
|
||||
const direction = action === 'in' ? 1 : -1;
|
||||
|
||||
if (direction > 0 ? base >= ZOOM_LEVEL_MAX : base <= ZOOM_LEVEL_MIN) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const steps = base / ZOOM_LEVEL_STEP;
|
||||
// A level between grid points steps to the next grid point in the
|
||||
// requested direction instead of skipping past it.
|
||||
const nextSteps = Number.isInteger(steps)
|
||||
? steps + direction
|
||||
: direction > 0
|
||||
? Math.ceil(steps)
|
||||
: Math.floor(steps);
|
||||
|
||||
return clampZoomLevel(nextSteps * ZOOM_LEVEL_STEP);
|
||||
}
|
||||
Reference in new issue
Block a user