feat(updater): nightly builds and a stable/nightly update channel (#1608)

* feat(updater): nightly builds and a stable/nightly update channel

Every master push publishes its artifacts as a prerelease of
4gray/iptvnator-nightly instead of the rolling test-master draft, with a
version of <next patch>-nightly.<commit date>.<run number> applied in
every build job. Settings → About gains an Update channel switch;
AppUpdateService re-points electron-updater per check (feed repository,
allowPrerelease, channel name, allowDowngrade reset) and reads release
notes from the repository the requested version belongs to. Channel
switches are forward-only: a nightly build stays until a newer stable
release exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(updater): compute the nightly version once and keep re-runs safe

Review follow-ups: the nightly version is resolved by a leading job and
handed to every build job, and the patch is bumped only when the base
tag already exists so the release-cut window stays below the imminent
release. A re-run never deletes a published nightly; only a draft left
by a failed run is replaced. Typed update-status literals in the
remaining specs carry the new channel fields.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(packaging): expect the nightly-version prerequisite in the build workflow graph

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-15 17:49:22 +02:00
1 parent 7f724494b9
commit 6f7973a9fb
57 files changed
+2249 -229

No files matched your search

@@ -14,8 +14,9 @@ jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(
(channel: string, handler: (...args: unknown[]) => unknown) => {
mockHandlers.set(channel, handler);
}),
mockHandlers.set(channel, handler);
}
),
},
}));
@@ -32,6 +33,8 @@ describe('AppUpdateEvents', () => {
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
supportedSelfUpdate: true,
channel: 'stable',
installedChannel: 'stable',
};
const service = {
checkForUpdates: jest.fn().mockResolvedValue({
@@ -59,7 +62,8 @@ describe('AppUpdateEvents', () => {
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
})),
};
const { default: AppUpdateEvents } = await import('./app-update.events');
const { default: AppUpdateEvents } =
await import('./app-update.events');
AppUpdateEvents.bootstrapAppUpdateEvents(service);
@@ -22,6 +22,7 @@ type SettingsUpdateHandler = (
const SETTINGS_UPDATE = 'SETTINGS_UPDATE';
const STORE_KEYS = {
APP_UPDATE_CHANNEL: 'APP_UPDATE_CHANNEL',
EMBEDDED_MPV_AUTO_RECONNECT: 'EMBEDDED_MPV_AUTO_RECONNECT',
EMBEDDED_MPV_EXTRA_OPTIONS: 'EMBEDDED_MPV_EXTRA_OPTIONS',
EMBEDDED_MPV_FRAME_COPY: 'EMBEDDED_MPV_FRAME_COPY',
@@ -50,6 +51,7 @@ jest.mock('electron', () => ({
}));
jest.mock('../services/store.service', () => ({
APP_UPDATE_CHANNEL: STORE_KEYS.APP_UPDATE_CHANNEL,
EMBEDDED_MPV_AUTO_RECONNECT: STORE_KEYS.EMBEDDED_MPV_AUTO_RECONNECT,
EMBEDDED_MPV_EXTRA_OPTIONS: STORE_KEYS.EMBEDDED_MPV_EXTRA_OPTIONS,
EMBEDDED_MPV_FRAME_COPY: STORE_KEYS.EMBEDDED_MPV_FRAME_COPY,
@@ -219,6 +221,37 @@ describe('SETTINGS_UPDATE', () => {
]);
});
it('mirrors the update channel into the main-process store and notifies the updater', async () => {
const channelModule = await import('../services/app-update-channel');
const listener = jest.fn();
channelModule.onAppUpdateChannelChange(listener);
settingsUpdateHandler({}, { updateChannel: 'nightly' });
expect(mockStoreSet.mock.calls).toEqual([
[STORE_KEYS.APP_UPDATE_CHANNEL, 'nightly'],
]);
expect(listener).toHaveBeenCalledWith('nightly');
});
it('normalizes an unknown update channel to stable and stays quiet when nothing changed', async () => {
const channelModule = await import('../services/app-update-channel');
const listener = jest.fn();
channelModule.onAppUpdateChannelChange(listener);
settingsUpdateHandler(
{},
{
updateChannel: 'canary' as unknown as 'stable',
}
);
expect(mockStoreSet.mock.calls).toEqual([
[STORE_KEYS.APP_UPDATE_CHANNEL, 'stable'],
]);
expect(listener).not.toHaveBeenCalled();
});
it('reconciles an enabled remote-control update with the stored port', () => {
mockStoreGet.mockImplementation(
(key: string, fallbackValue: unknown): unknown =>
@@ -19,6 +19,7 @@ import {
} from '../services/store.service';
import { httpServer } from '../server/http-server';
import { setHostConnectivityGuardEnabled } from '../util/host-connectivity-guard';
import { persistAppUpdateChannel } from '../services/app-update-channel';
export default class SettingsEvents {
static bootstrapSettingsEvents(): Electron.IpcMain {
@@ -91,6 +92,12 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
store.set(VLC_REUSE_INSTANCE, arg.vlcReuseInstance);
}
// Mirrored for the startup update check, which runs before the renderer
// exists; a change re-points the running updater immediately.
if (arg.updateChannel !== undefined) {
persistAppUpdateChannel(arg.updateChannel);
}
// Handle remote control settings
if (
arg.remoteControl !== undefined ||
@@ -0,0 +1,48 @@
import {
AppUpdateChannel,
normalizeAppUpdateChannel,
} from '@iptvnator/shared/interfaces';
import { APP_UPDATE_CHANNEL, store } from './store.service';
type AppUpdateChannelListener = (channel: AppUpdateChannel) => void;
const listeners = new Set<AppUpdateChannelListener>();
/**
* The persisted update channel, read synchronously from the main-process
* config so the startup update check can use it before any renderer exists.
* Junk in the config file collapses to `stable`.
*/
export function readStoredAppUpdateChannel(): AppUpdateChannel {
return normalizeAppUpdateChannel(store.get(APP_UPDATE_CHANNEL));
}
/**
* Mirrors a saved `Settings.updateChannel` into the config file and tells
* the updater. Listeners only run on an actual change, so a Save that keeps
* the channel cannot restart an update check.
*/
export function persistAppUpdateChannel(value: unknown): AppUpdateChannel {
const channel = normalizeAppUpdateChannel(value);
const previous = readStoredAppUpdateChannel();
store.set(APP_UPDATE_CHANNEL, channel);
if (channel !== previous) {
for (const listener of listeners) {
listener(channel);
}
}
return channel;
}
export function onAppUpdateChannelChange(
listener: AppUpdateChannelListener
): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
@@ -0,0 +1,48 @@
import {
AppUpdateChannel,
appUpdateRepository,
NIGHTLY_PRERELEASE_TAG,
} from '@iptvnator/shared/interfaces';
export interface AppUpdateGitHubFeedOptions {
provider: 'github';
owner: string;
repo: string;
}
/**
* The slice of electron-updater's `AppUpdater` the channel switch touches.
* Kept structural so the service spec can drive it with a fake.
*/
export interface AppUpdateFeedTarget {
allowPrerelease: boolean;
allowDowngrade: boolean;
channel: string | null;
setFeedURL(options: AppUpdateGitHubFeedOptions): void;
}
/**
* Points electron-updater at the repository behind `channel` right before a
* check. Applied on every check rather than once, because the channel can
* change while the app runs.
*
* Two electron-updater quirks decide the shape of this function:
*
* - The `channel` setter throws once a string channel is replaced by `null`,
* so stable is written as the explicit `latest` (the default channel
* file name) instead of being cleared.
* - Assigning `channel` silently flips `allowDowngrade` to `true`, and the
* constructor turns `allowPrerelease` on for any prerelease build. Both
* are reset afterwards: a nightly build on the stable channel must wait
* for the next stable release rather than downgrade onto one that may
* not understand its database schema.
*/
export function applyAppUpdateChannel(
updater: AppUpdateFeedTarget,
channel: AppUpdateChannel
): void {
updater.setFeedURL({ provider: 'github', ...appUpdateRepository(channel) });
updater.allowPrerelease = channel === 'nightly';
updater.channel = channel === 'nightly' ? NIGHTLY_PRERELEASE_TAG : 'latest';
updater.allowDowngrade = false;
}
@@ -0,0 +1,178 @@
import {
AppUpdateChannel,
appUpdateReleasesApiUrl,
appUpdateReleasesPageUrl,
appVersionChannel,
} from '@iptvnator/shared/interfaces';
const GITHUB_RELEASES_PER_PAGE = 10;
interface GitHubReleaseResponse {
body?: string | null;
draft?: boolean;
html_url?: string;
name?: string | null;
prerelease?: boolean;
published_at?: string | null;
tag_name?: string;
}
export interface CachedGitHubRelease {
bodyMarkdown: string;
draft: boolean;
htmlUrl: string;
prerelease: boolean;
publishedAt?: string | null;
releaseName?: string | null;
tagName: string;
version: string;
}
export interface ReleaseFetchResponse {
json(): Promise<unknown>;
ok: boolean;
status: number;
statusText: string;
}
export type ReleaseFetcher = (
url: string,
init?: { headers?: Record<string, string> }
) => Promise<ReleaseFetchResponse>;
export function normalizeVersion(value: string | null | undefined): string {
return (value ?? '').trim().replace(/^v/i, '');
}
function isGitHubRelease(value: unknown): value is GitHubReleaseResponse {
return Boolean(
value &&
typeof value === 'object' &&
'tag_name' in value &&
typeof (value as GitHubReleaseResponse).tag_name === 'string'
);
}
/**
* Lazily paged, newest-first list of one channel's published GitHub
* releases. Drafts are never listed. The stable catalog also drops
* prereleases (a beta tag must not become "the latest version"), while the
* nightly catalog keeps only releases whose version carries the nightly
* identifier, so a stray tag in that repository cannot be offered either.
*/
export class AppUpdateReleaseCatalog {
readonly releases: CachedGitHubRelease[] = [];
loadedReleasePages = 0;
loadedAllReleases = false;
constructor(
readonly channel: AppUpdateChannel,
private readonly fetcher: ReleaseFetcher,
private readonly userAgent: string
) {}
get latest(): CachedGitHubRelease | undefined {
return this.releases[0];
}
async ensureFirstReleaseLoaded(): Promise<void> {
while (!this.loadedAllReleases && this.releases.length === 0) {
await this.ensurePageLoaded(this.loadedReleasePages + 1);
}
}
async ensurePageLoaded(page: number): Promise<void> {
while (!this.loadedAllReleases && this.loadedReleasePages < page) {
await this.loadPage(this.loadedReleasePages + 1);
}
}
/** Index of `version` (or its tag), paging further until it is found. */
async findIndex(version: string | undefined): Promise<number> {
if (!version) {
return -1;
}
const normalizedVersion = normalizeVersion(version);
while (true) {
const index = this.releases.findIndex(
(release) =>
normalizeVersion(release.version) === normalizedVersion ||
normalizeVersion(release.tagName) === normalizedVersion
);
if (index !== -1 || this.loadedAllReleases) {
return index;
}
await this.ensurePageLoaded(this.loadedReleasePages + 1);
}
}
private belongsToChannel(release: GitHubReleaseResponse): boolean {
if (release.draft) {
return false;
}
if (this.channel === 'nightly') {
return appVersionChannel(release.tag_name) === 'nightly';
}
return !release.prerelease;
}
private async loadPage(page: number): Promise<void> {
const url = `${appUpdateReleasesApiUrl(this.channel)}?per_page=${GITHUB_RELEASES_PER_PAGE}&page=${page}`;
const response = await this.fetcher(url, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': this.userAgent,
},
});
if (!response.ok) {
throw new Error(
`GitHub releases request failed: ${response.status} ${response.statusText}`
);
}
const payload = await response.json();
if (!Array.isArray(payload)) {
throw new Error('GitHub releases response was not an array');
}
const releasesPageUrl = appUpdateReleasesPageUrl(this.channel).replace(
/\/latest$/,
''
);
for (const release of payload) {
if (!isGitHubRelease(release) || !this.belongsToChannel(release)) {
continue;
}
const tagName = release.tag_name;
if (this.releases.some((cached) => cached.tagName === tagName)) {
continue;
}
this.releases.push({
bodyMarkdown: release.body ?? '',
draft: Boolean(release.draft),
htmlUrl:
release.html_url ?? `${releasesPageUrl}/tag/${tagName}`,
prerelease: Boolean(release.prerelease),
publishedAt: release.published_at,
releaseName: release.name ?? tagName,
tagName,
version: normalizeVersion(tagName),
});
}
this.loadedReleasePages = page;
this.loadedAllReleases = payload.length < GITHUB_RELEASES_PER_PAGE;
}
}
@@ -1,5 +1,6 @@
import { EventEmitter } from 'events';
import {
AppUpdateChannel,
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
@@ -8,11 +9,66 @@ import { AppUpdateService } from './app-update.service';
class FakeUpdater extends EventEmitter {
autoDownload = true;
autoInstallOnAppQuit = true;
// electron-updater turns these on by itself for prerelease builds and
// channel assignments; the service must reset them explicitly.
allowPrerelease = true;
allowDowngrade = true;
channel: string | null = null;
setFeedURL = jest.fn();
checkForUpdates = jest.fn().mockResolvedValue(null);
downloadUpdate = jest.fn().mockResolvedValue([]);
quitAndInstall = jest.fn();
}
const nightlyReleases = [
{
body: '## Nightly\n\nNewest master build\n\n<!-- iptvnator-commit: abc -->',
draft: false,
html_url:
'https://github.com/4gray/iptvnator-nightly/releases/tag/v0.23.1-nightly.20260915.7',
name: 'Nightly 0.23.1-nightly.20260915.7',
prerelease: true,
published_at: '2026-09-15T00:00:00.000Z',
tag_name: 'v0.23.1-nightly.20260915.7',
},
{
body: 'not a nightly',
draft: false,
html_url:
'https://github.com/4gray/iptvnator-nightly/releases/tag/v9.9.9',
name: 'stray tag',
prerelease: false,
published_at: '2026-09-14T12:00:00.000Z',
tag_name: 'v9.9.9',
},
{
body: '## Nightly\n\nOlder master build',
draft: false,
html_url:
'https://github.com/4gray/iptvnator-nightly/releases/tag/v0.23.1-nightly.20260914.5',
name: 'Nightly 0.23.1-nightly.20260914.5',
prerelease: true,
published_at: '2026-09-14T00:00:00.000Z',
tag_name: 'v0.23.1-nightly.20260914.5',
},
];
/** Answers the stable and nightly repositories with their own lists. */
function createChannelReleaseFetcher() {
return jest.fn(async (url: string) => {
const releases = url.includes('/iptvnator-nightly/')
? nightlyReleases
: githubReleases;
return {
json: jest.fn().mockResolvedValue(releases),
ok: true,
status: 200,
statusText: 'OK',
};
});
}
const githubReleases = [
{
body: '## New\n\nFresh build',
@@ -84,6 +140,8 @@ function createService(
env?: NodeJS.ProcessEnv;
prepareQuit?: () => void;
cancelPreparedQuit?: () => void;
channel?: AppUpdateChannel;
appVersion?: string;
} = {}
) {
const updater = new FakeUpdater();
@@ -93,6 +151,8 @@ function createService(
getVersion: () => '0.22.0',
isPackaged: overrides.isPackaged ?? true,
},
appVersion: overrides.appVersion,
channel: overrides.channel,
getMainWindow: () => win,
cancelPreparedQuit: overrides.cancelPreparedQuit,
platform: overrides.platform ?? 'darwin',
@@ -115,6 +175,8 @@ describe('AppUpdateService', () => {
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
channel: 'stable',
installedChannel: 'stable',
});
expect(updater.autoDownload).toBe(true);
});
@@ -136,6 +198,8 @@ describe('AppUpdateService', () => {
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
channel: 'stable',
installedChannel: 'stable',
});
await service.checkForUpdatesOnStartup();
@@ -160,6 +224,8 @@ describe('AppUpdateService', () => {
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
channel: 'stable',
installedChannel: 'stable',
});
});
@@ -281,6 +347,8 @@ describe('AppUpdateService', () => {
},
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
supportedSelfUpdate: false,
channel: 'stable',
installedChannel: 'stable',
});
});
@@ -312,6 +380,8 @@ describe('AppUpdateService', () => {
latestVersion: '0.24.0',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
supportedSelfUpdate: false,
channel: 'stable',
installedChannel: 'stable',
});
});
@@ -534,4 +604,186 @@ describe('AppUpdateService', () => {
expect(cancelPreparedQuit).not.toHaveBeenCalled();
});
it('points electron-updater at the stable repository before every check', async () => {
const { service, updater } = createService();
await service.checkForUpdates();
expect(updater.setFeedURL).toHaveBeenCalledWith({
provider: 'github',
owner: '4gray',
repo: 'iptvnator',
});
expect(updater.allowPrerelease).toBe(false);
expect(updater.channel).toBe('latest');
expect(updater.allowDowngrade).toBe(false);
expect(updater.setFeedURL.mock.invocationCallOrder[0]).toBeLessThan(
updater.checkForUpdates.mock.invocationCallOrder[0]
);
});
it('follows the nightly repository and its prerelease channel when configured', async () => {
const { service, updater } = createService({ channel: 'nightly' });
expect(service.getStatus()).toMatchObject({
channel: 'nightly',
installedChannel: 'stable',
manualDownloadUrl:
'https://github.com/4gray/iptvnator-nightly/releases',
});
await service.checkForUpdates();
expect(updater.setFeedURL).toHaveBeenCalledWith({
provider: 'github',
owner: '4gray',
repo: 'iptvnator-nightly',
});
expect(updater.allowPrerelease).toBe(true);
expect(updater.channel).toBe('nightly');
expect(updater.allowDowngrade).toBe(false);
});
it('reports the installed channel from the running version', () => {
const { service } = createService({
appVersion: '0.23.1-nightly.20260915.7',
});
expect(service.getStatus()).toMatchObject({
currentVersion: '0.23.1-nightly.20260915.7',
channel: 'stable',
installedChannel: 'nightly',
});
});
it('re-checks on an idle updater when the channel changes and forgets the old verdict', async () => {
const { service, updater, win } = createService();
await service.checkForUpdates();
service.handleUpdateNotAvailable({ version: '0.22.0' });
updater.checkForUpdates.mockClear();
service.setChannel('stable');
expect(updater.checkForUpdates).not.toHaveBeenCalled();
service.setChannel('nightly');
await Promise.resolve();
expect(updater.checkForUpdates).toHaveBeenCalledTimes(1);
expect(updater.setFeedURL).toHaveBeenLastCalledWith(
expect.objectContaining({ repo: 'iptvnator-nightly' })
);
const pushed = (
win.webContents.send.mock.calls as [
string,
ElectronBridgeAppUpdateStatus,
][]
).map(([, status]) => status);
expect(
pushed.some(
(status) =>
status.channel === 'nightly' &&
status.latestVersion === undefined &&
status.release === undefined
)
).toBe(true);
});
it('leaves a running download on the old channel but reports the new one', async () => {
const { service, updater } = createService();
service.handleUpdateAvailable({ version: '0.23.0' });
updater.downloadUpdate.mockImplementationOnce(
() => new Promise(() => undefined)
);
void service.downloadUpdate();
updater.checkForUpdates.mockClear();
service.setChannel('nightly');
await Promise.resolve();
expect(updater.checkForUpdates).not.toHaveBeenCalled();
expect(service.getStatus()).toMatchObject({
channel: 'nightly',
latestVersion: '0.23.0',
manualDownloadUrl:
'https://github.com/4gray/iptvnator-nightly/releases',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
});
});
it('offers newer nightlies through the manual fallback using prerelease-aware ordering', async () => {
const fetcher = createChannelReleaseFetcher();
const { service, updater } = createService({
appVersion: '0.23.1-nightly.20260914.5',
channel: 'nightly',
env: {},
fetcher,
platform: 'linux',
});
await service.checkForUpdates();
expect(updater.checkForUpdates).not.toHaveBeenCalled();
expect(fetcher).toHaveBeenCalledWith(
'https://api.github.com/repos/4gray/iptvnator-nightly/releases?per_page=10&page=1',
expect.any(Object)
);
// v9.9.9 is not a nightly and must not win the list.
expect(service.getStatus()).toMatchObject({
latestVersion: '0.23.1-nightly.20260915.7',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
installedChannel: 'nightly',
});
});
it('never downgrades a nightly build that follows the stable channel', async () => {
const fetcher = createChannelReleaseFetcher();
const { service } = createService({
appVersion: '0.24.1-nightly.20260915.7',
env: {},
fetcher,
platform: 'linux',
});
await service.checkForUpdates();
expect(service.getStatus()).toMatchObject({
latestVersion: '0.24.0',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
installedChannel: 'nightly',
});
});
it('reads release notes from the repository the requested version belongs to', async () => {
const fetcher = createChannelReleaseFetcher();
const { service } = createService({ fetcher });
const nightlyNotes = await service.getReleaseNotes({
version: '0.23.1-nightly.20260915.7',
});
expect(nightlyNotes).toMatchObject({
tagName: 'v0.23.1-nightly.20260915.7',
hasNext: false,
hasPrevious: true,
});
const previous = await service.getReleaseNotes({
version: nightlyNotes.tagName,
direction: 'previous',
});
expect(previous.tagName).toBe('v0.23.1-nightly.20260914.5');
const stableNotes = await service.getReleaseNotes({
version: '0.23.0',
});
expect(stableNotes.tagName).toBe('v0.23.0');
const latestOnStableChannel = await service.getReleaseNotes();
expect(latestOnStableChannel.tagName).toBe('v0.24.0');
expect(
fetcher.mock.calls.map(([url]) => new URL(url).pathname)
).toEqual([
'/repos/4gray/iptvnator-nightly/releases',
'/repos/4gray/iptvnator/releases',
]);
});
});
@@ -1,17 +1,28 @@
import {
APP_UPDATE_STATUS_CHANGED,
AppUpdateChannel,
appUpdateReleasesPageUrl,
appVersionChannel,
compareAppVersions,
DEFAULT_APP_UPDATE_CHANNEL,
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateRelease,
ElectronBridgeAppUpdateReleaseNotes,
ElectronBridgeAppUpdateReleaseNotesRequest,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { AppUpdateFeedTarget, applyAppUpdateChannel } from './app-update-feed';
import {
AppUpdateReleaseCatalog,
CachedGitHubRelease,
normalizeVersion,
ReleaseFetcher,
ReleaseFetchResponse,
} from './app-update-release-catalog';
export const APP_UPDATE_MANUAL_DOWNLOAD_URL =
'https://github.com/4gray/iptvnator/releases/latest';
const GITHUB_RELEASES_API_URL =
'https://api.github.com/repos/4gray/iptvnator/releases';
const GITHUB_RELEASES_PER_PAGE = 10;
export const APP_UPDATE_MANUAL_DOWNLOAD_URL = appUpdateReleasesPageUrl(
DEFAULT_APP_UPDATE_CHANNEL
);
interface AppUpdateAppAdapter {
getVersion(): string;
@@ -41,7 +52,7 @@ interface AppUpdateProgressInfo {
transferred?: number;
}
interface AppUpdaterAdapter {
interface AppUpdaterAdapter extends AppUpdateFeedTarget {
autoDownload: boolean;
autoInstallOnAppQuit?: boolean;
checkForUpdates(): Promise<unknown>;
@@ -69,39 +80,6 @@ interface AppUpdaterAdapter {
type AppUpdaterAdapterProvider = AppUpdaterAdapter | (() => AppUpdaterAdapter);
interface GitHubReleaseResponse {
body?: string | null;
draft?: boolean;
html_url?: string;
name?: string | null;
prerelease?: boolean;
published_at?: string | null;
tag_name?: string;
}
interface CachedGitHubRelease {
bodyMarkdown: string;
draft: boolean;
htmlUrl: string;
prerelease: boolean;
publishedAt?: string | null;
releaseName?: string | null;
tagName: string;
version: string;
}
interface ReleaseFetchResponse {
json(): Promise<unknown>;
ok: boolean;
status: number;
statusText: string;
}
type ReleaseFetcher = (
url: string,
init?: { headers?: Record<string, string> }
) => Promise<ReleaseFetchResponse>;
export interface AppUpdateServiceOptions {
app: AppUpdateAppAdapter;
appVersion?: string;
@@ -110,6 +88,8 @@ export interface AppUpdateServiceOptions {
platform?: NodeJS.Platform;
processEnv?: NodeJS.ProcessEnv;
releaseFetcher?: ReleaseFetcher;
/** Persisted update channel at startup; `setChannel` follows later saves. */
channel?: AppUpdateChannel;
/**
* Runs right before `quitAndInstall()`. Lets the unsaved-settings close
* guard stand down for the updater-driven window close, which on macOS
@@ -165,37 +145,6 @@ function normalizeError(error: unknown): string {
return String(error);
}
function normalizeVersion(value: string | null | undefined): string {
return (value ?? '').trim().replace(/^v/i, '');
}
function parseVersionParts(value: string): [number, number, number] | null {
const match = normalizeVersion(value).match(/^(\d+)\.(\d+)\.(\d+)/);
if (!match) {
return null;
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function isVersionGreaterThan(candidate: string, current: string): boolean {
const candidateParts = parseVersionParts(candidate);
const currentParts = parseVersionParts(current);
if (!candidateParts || !currentParts) {
return false;
}
for (let index = 0; index < candidateParts.length; index += 1) {
if (candidateParts[index] !== currentParts[index]) {
return candidateParts[index] > currentParts[index];
}
}
return false;
}
function resolveCurrentVersion(
app: AppUpdateAppAdapter,
appVersion: string | undefined
@@ -217,8 +166,7 @@ function toReleaseInfo(
function toReleaseNotes(
release: CachedGitHubRelease,
index: number,
releaseCount: number,
loadedAllReleases: boolean
catalog: AppUpdateReleaseCatalog
): ElectronBridgeAppUpdateReleaseNotes {
return {
version: release.version,
@@ -228,28 +176,22 @@ function toReleaseNotes(
bodyMarkdown: release.bodyMarkdown,
htmlUrl: release.htmlUrl,
hasNext: index > 0,
hasPrevious: index < releaseCount - 1 || !loadedAllReleases,
hasPrevious:
index < catalog.releases.length - 1 || !catalog.loadedAllReleases,
};
}
function isGitHubRelease(value: unknown): value is GitHubReleaseResponse {
return Boolean(
value &&
typeof value === 'object' &&
'tag_name' in value &&
typeof (value as GitHubReleaseResponse).tag_name === 'string'
);
}
export class AppUpdateService {
private readonly currentVersion: string;
private readonly isPackaged: boolean;
private readonly supportedSelfUpdate: boolean;
private readonly releaseFetcher: ReleaseFetcher;
private readonly releases: CachedGitHubRelease[] = [];
private readonly catalogs = new Map<
AppUpdateChannel,
AppUpdateReleaseCatalog
>();
private readonly updater: AppUpdaterAdapter | null = null;
private loadedReleasePages = 0;
private loadedAllReleases = false;
private channel: AppUpdateChannel;
private checkForUpdatesPromise: Promise<ElectronBridgeAppUpdateStatus> | null =
null;
private status: ElectronBridgeAppUpdateStatus;
@@ -262,6 +204,7 @@ export class AppUpdateService {
options.appVersion
);
this.isPackaged = options.app.isPackaged;
this.channel = options.channel ?? DEFAULT_APP_UPDATE_CHANNEL;
this.supportedSelfUpdate = isSelfUpdateSupported(
options.app.isPackaged,
options.platform ?? process.platform,
@@ -269,12 +212,15 @@ export class AppUpdateService {
);
this.releaseFetcher =
options.releaseFetcher ??
((url, init) => fetch(url, init) as Promise<ReleaseFetchResponse>);
((url, init) =>
fetch(url, init) as Promise<ReleaseFetchResponse>);
this.status = {
currentVersion: this.currentVersion,
manualDownloadUrl: APP_UPDATE_MANUAL_DOWNLOAD_URL,
manualDownloadUrl: appUpdateReleasesPageUrl(this.channel),
status: getInitialStatus(this.supportedSelfUpdate),
supportedSelfUpdate: this.supportedSelfUpdate,
channel: this.channel,
installedChannel: appVersionChannel(this.currentVersion),
};
if (this.supportedSelfUpdate) {
@@ -289,6 +235,33 @@ export class AppUpdateService {
return { ...this.status };
}
/**
* Follows a saved channel change. A download already running or
* finished belongs to the previous channel and is left alone — the
* user can still install it — so only an idle updater re-checks.
*/
setChannel(channel: AppUpdateChannel): void {
if (channel === this.channel) {
return;
}
this.channel = channel;
const busy =
this.status.status ===
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading ||
this.status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded;
if (busy || !this.isPackaged) {
this.setStatus({});
return;
}
// The last verdict described the other channel's releases.
this.setStatus({ latestVersion: undefined, release: undefined });
void this.checkForUpdates();
}
async checkForUpdates(): Promise<ElectronBridgeAppUpdateStatus> {
if (this.checkForUpdatesPromise) {
return this.checkForUpdatesPromise;
@@ -311,11 +284,14 @@ export class AppUpdateService {
this.setStatus({
error: undefined,
latestVersion: undefined,
release: undefined,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking,
});
try {
if (this.updater) {
applyAppUpdateChannel(this.updater, this.channel);
await this.updater.checkForUpdates();
} else {
await this.checkGitHubReleaseForManualUpdate();
@@ -335,19 +311,28 @@ export class AppUpdateService {
return this.checkForUpdates();
}
/**
* Release notes come from the catalog of the channel the requested
* version belongs to, not the configured one: a nightly build on the
* stable channel still reads its own notes, and paging from a nightly
* tag stays inside the nightly list.
*/
async getReleaseNotes(
request: ElectronBridgeAppUpdateReleaseNotesRequest = {}
): Promise<ElectronBridgeAppUpdateReleaseNotes> {
const catalog = this.catalogFor(
request.version ? appVersionChannel(request.version) : this.channel
);
const canFallbackToLatest =
!request.direction && (!request.version || request.fallbackToLatest);
if (canFallbackToLatest) {
await this.ensureFirstStableReleaseLoaded();
await catalog.ensureFirstReleaseLoaded();
} else {
await this.ensureReleasePageLoaded(1);
await catalog.ensurePageLoaded(1);
}
let index = await this.findReleaseIndex(request.version);
let index = await catalog.findIndex(request.version);
if (index === -1 && canFallbackToLatest) {
index = 0;
@@ -361,25 +346,20 @@ export class AppUpdateService {
if (request.direction === 'previous') {
index += 1;
while (index >= this.releases.length && !this.loadedAllReleases) {
await this.ensureReleasePageLoaded(this.loadedReleasePages + 1);
while (index >= catalog.releases.length && !catalog.loadedAllReleases) {
await catalog.ensurePageLoaded(catalog.loadedReleasePages + 1);
}
} else if (request.direction === 'next') {
index -= 1;
}
const release = this.releases[index];
const release = catalog.releases[index];
if (!release) {
throw new Error('No release notes are available in that direction');
}
return toReleaseNotes(
release,
index,
this.releases.length,
this.loadedAllReleases
);
return toReleaseNotes(release, index, catalog);
}
async downloadUpdate(): Promise<ElectronBridgeAppUpdateStatus> {
@@ -489,6 +469,21 @@ export class AppUpdateService {
});
}
private catalogFor(channel: AppUpdateChannel): AppUpdateReleaseCatalog {
let catalog = this.catalogs.get(channel);
if (!catalog) {
catalog = new AppUpdateReleaseCatalog(
channel,
this.releaseFetcher,
`iptvnator/${this.currentVersion}`
);
this.catalogs.set(channel, catalog);
}
return catalog;
}
private resolveUpdater(
updater: AppUpdaterAdapterProvider
): AppUpdaterAdapter {
@@ -517,9 +512,15 @@ export class AppUpdateService {
updater.on('error', (error) => this.handleError(error));
}
/**
* Packaged builds without self-update (Linux deb/rpm/snap/…) only learn
* whether a newer version exists on the configured channel and point
* the user at its release page.
*/
private async checkGitHubReleaseForManualUpdate(): Promise<void> {
await this.ensureFirstStableReleaseLoaded();
const latestRelease = this.releases[0];
const catalog = this.catalogFor(this.channel);
await catalog.ensureFirstReleaseLoaded();
const latestRelease = catalog.latest;
if (!latestRelease) {
this.setStatus({
@@ -528,124 +529,28 @@ export class AppUpdateService {
return;
}
if (isVersionGreaterThan(latestRelease.version, this.currentVersion)) {
this.setStatus({
error: undefined,
latestVersion: latestRelease.version,
release: toReleaseInfo(latestRelease),
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
});
return;
}
const isNewer =
compareAppVersions(latestRelease.version, this.currentVersion) > 0;
this.setStatus({
error: undefined,
latestVersion: latestRelease.version,
release: toReleaseInfo(latestRelease),
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
status: isNewer
? ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available
: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
});
}
private async ensureFirstStableReleaseLoaded(): Promise<void> {
while (!this.loadedAllReleases && this.releases.length === 0) {
await this.ensureReleasePageLoaded(this.loadedReleasePages + 1);
}
}
private async findReleaseIndex(
version: string | undefined
): Promise<number> {
if (!version) {
return -1;
}
const normalizedVersion = normalizeVersion(version);
while (true) {
const index = this.releases.findIndex(
(release) =>
normalizeVersion(release.version) === normalizedVersion ||
normalizeVersion(release.tagName) === normalizedVersion
);
if (index !== -1 || this.loadedAllReleases) {
return index;
}
await this.ensureReleasePageLoaded(this.loadedReleasePages + 1);
}
}
private async ensureReleasePageLoaded(page: number): Promise<void> {
while (!this.loadedAllReleases && this.loadedReleasePages < page) {
await this.loadReleasePage(this.loadedReleasePages + 1);
}
}
private async loadReleasePage(page: number): Promise<void> {
const url = `${GITHUB_RELEASES_API_URL}?per_page=${GITHUB_RELEASES_PER_PAGE}&page=${page}`;
const response = await this.releaseFetcher(url, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': `iptvnator/${this.currentVersion}`,
},
});
if (!response.ok) {
throw new Error(
`GitHub releases request failed: ${response.status} ${response.statusText}`
);
}
const payload = await response.json();
if (!Array.isArray(payload)) {
throw new Error('GitHub releases response was not an array');
}
for (const release of payload) {
if (!isGitHubRelease(release)) {
continue;
}
if (release.draft || release.prerelease) {
continue;
}
const tagName = release.tag_name;
const version = normalizeVersion(tagName);
if (
this.releases.some(
(cachedRelease) => cachedRelease.tagName === tagName
)
) {
continue;
}
this.releases.push({
bodyMarkdown: release.body ?? '',
draft: Boolean(release.draft),
htmlUrl:
release.html_url ??
`${APP_UPDATE_MANUAL_DOWNLOAD_URL.replace('/latest', '')}/tag/${tagName}`,
prerelease: Boolean(release.prerelease),
publishedAt: release.published_at,
releaseName: release.name ?? tagName,
tagName,
version,
});
}
this.loadedReleasePages = page;
this.loadedAllReleases = payload.length < GITHUB_RELEASES_PER_PAGE;
}
private setStatus(
update: Partial<
Omit<
ElectronBridgeAppUpdateStatus,
'currentVersion' | 'manualDownloadUrl' | 'supportedSelfUpdate'
| 'currentVersion'
| 'manualDownloadUrl'
| 'supportedSelfUpdate'
| 'channel'
| 'installedChannel'
>
>
): void {
@@ -653,8 +558,10 @@ export class AppUpdateService {
...this.status,
...update,
currentVersion: this.currentVersion,
manualDownloadUrl: APP_UPDATE_MANUAL_DOWNLOAD_URL,
manualDownloadUrl: appUpdateReleasesPageUrl(this.channel),
supportedSelfUpdate: this.supportedSelfUpdate,
channel: this.channel,
installedChannel: this.status.installedChannel,
};
this.emitStatus();
}
@@ -1,6 +1,9 @@
import { Conf } from 'electron-conf/main';
import { getElectronConfigDirectory } from '@iptvnator/shared/database';
import type { StartupWindowMode } from '@iptvnator/shared/interfaces';
import type {
AppUpdateChannel,
StartupWindowMode,
} from '@iptvnator/shared/interfaces';
export const WINDOW_BOUNDS = 'WINDOW_BOUNDS';
export const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH';
@@ -28,6 +31,14 @@ export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE';
/** Desktop portal request cooldown; absent means enabled. */
export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD';
/**
* Update channel (`stable` / `nightly`). Mirrored here from the renderer's
* settings by the SETTINGS_UPDATE handler because the startup update check
* runs before the renderer's IndexedDB settings are reachable; absent means
* `stable`. Read and written only through `app-update-channel.ts`.
*/
export const APP_UPDATE_CHANNEL = 'APP_UPDATE_CHANNEL';
/**
* Local XMLTV files the EPG importer may read: every path the native picker
* returned plus every hand-typed path the user allowed in the main-process
@@ -64,6 +75,7 @@ export type StoreType = {
[EMBEDDED_MPV_AUTO_RECONNECT]: boolean;
[STARTUP_WINDOW_MODE]: StartupWindowMode;
[PORTAL_CONNECTIVITY_GUARD]: boolean;
[APP_UPDATE_CHANNEL]: AppUpdateChannel;
[TRUSTED_LOCAL_EPG_SOURCES]: string[];
};
+8
View File
@@ -33,6 +33,10 @@ import StalkerEvents from './app/events/stalker.events';
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
import { registerStaticHeaderShims } from './app/services/request-header-overrides.service';
import { AppUpdateService } from './app/services/app-update.service';
import {
onAppUpdateChannelChange,
readStoredAppUpdateChannel,
} from './app/services/app-update-channel';
import { databaseWorkerClient } from './app/services/database-worker-client';
import WindowEvents from './app/events/window.events';
import { bootstrapWindowCloseGuard } from './app/services/window-close-guard.service';
@@ -141,6 +145,7 @@ export default class Main {
const appUpdateService = new AppUpdateService({
app,
appVersion: environment.version,
channel: readStoredAppUpdateChannel(),
getMainWindow: () => App.mainWindow,
updater: () => autoUpdater,
// quitAndInstall() closes the windows before 'before-quit' fires
@@ -150,6 +155,9 @@ export default class Main {
cancelPreparedQuit: () => windowCloseGuard.revokeAllowedClose(),
});
AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService);
onAppUpdateChannelChange((channel) =>
appUpdateService.setChannel(channel)
);
registerStaticHeaderShims();
ElectronEvents.bootstrapElectronEvents();
@@ -14,6 +14,8 @@ const availableStatus: ElectronBridgeAppUpdateStatus = {
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
supportedSelfUpdate: true,
channel: 'stable',
installedChannel: 'stable',
};
describe('AppUpdateNotificationPanelComponent', () => {
@@ -12,6 +12,9 @@ const BASE_STATUS: ElectronBridgeAppUpdateStatus = {
currentVersion: '0.23.0',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
supportedSelfUpdate: true,
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
channel: 'stable',
installedChannel: 'stable',
};
/**
@@ -63,6 +63,65 @@
></progress>
}
</div>
@if (canSelectUpdateChannel()) {
<div
class="app-update-channel"
[formGroup]="form()!"
data-test-id="app-update-channel"
>
<mat-form-field appearance="outline">
<mat-label>{{
'SETTINGS.APP_UPDATE_CHANNEL' | translate
}}</mat-label>
<mat-select
formControlName="updateChannel"
data-test-id="select-update-channel"
>
@for (
option of updateChannelOptions();
track option.value
) {
<mat-option
[value]="option.value"
[attr.data-test-id]="
'update-channel-' + option.value
"
>
{{ option.labelKey | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
<small class="app-update-channel__note">
{{
'SETTINGS.APP_UPDATE_CHANNEL_DESCRIPTION'
| translate
}}
</small>
@if (form()!.get('updateChannel')?.value === 'nightly') {
<small
class="app-update-channel__note"
data-test-id="app-update-channel-nightly-warning"
>
{{
'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY_WARNING'
| translate
}}
</small>
}
@if (isNightlyBuildOnStableChannel()) {
<small
class="app-update-channel__note"
data-test-id="app-update-nightly-on-stable-hint"
>
{{
'SETTINGS.APP_UPDATE_NIGHTLY_ON_STABLE_HINT'
| translate
}}
</small>
}
</div>
}
<div class="app-update-actions">
<button
mat-stroked-button
@@ -1,10 +1,12 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, FormGroup } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { SettingsAboutSectionComponent } from './settings-about-section.component';
import { SETTINGS_UPDATE_CHANNEL_OPTIONS } from './settings-options';
function getButton(fixture: ComponentFixture<SettingsAboutSectionComponent>, id: string) {
return fixture.nativeElement.querySelector(
@@ -187,3 +189,86 @@ describe('SettingsAboutSectionComponent version display', () => {
expect(getCommitMarker()).toBeNull();
});
});
describe('SettingsAboutSectionComponent update channel', () => {
let fixture: ComponentFixture<SettingsAboutSectionComponent>;
const query = (id: string) =>
fixture.nativeElement.querySelector(
`[data-test-id="${id}"]`
) as HTMLElement | null;
const configure = (
status: Partial<ElectronBridgeAppUpdateStatus>,
form: FormGroup | null
) => {
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('form', form);
fixture.componentRef.setInput(
'updateChannelOptions',
SETTINGS_UPDATE_CHANNEL_OPTIONS
);
fixture.componentRef.setInput('appUpdateStatus', {
currentVersion: '0.23.0',
manualDownloadUrl:
'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
supportedSelfUpdate: true,
channel: 'stable',
installedChannel: 'stable',
...status,
});
fixture.detectChanges();
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
SettingsAboutSectionComponent,
TranslateModule.forRoot(),
],
}).compileComponents();
fixture = TestBed.createComponent(SettingsAboutSectionComponent);
});
it('binds the channel select to the shared settings form', () => {
const form = new FormGroup({
updateChannel: new FormControl('stable'),
});
configure({}, form);
expect(query('select-update-channel')).not.toBeNull();
expect(query('app-update-channel-nightly-warning')).toBeNull();
form.controls.updateChannel.setValue('nightly');
fixture.detectChanges();
expect(query('app-update-channel-nightly-warning')).not.toBeNull();
});
it('renders no channel control without a form to bind to', () => {
configure({}, null);
expect(query('select-update-channel')).toBeNull();
});
it('explains that a nightly build on the stable channel waits for the next release', () => {
const form = new FormGroup({
updateChannel: new FormControl('stable'),
});
configure(
{
currentVersion: '0.23.1-nightly.20260915.7',
installedChannel: 'nightly',
},
form
);
expect(query('app-update-nightly-on-stable-hint')).not.toBeNull();
configure({ channel: 'nightly', installedChannel: 'nightly' }, form);
expect(query('app-update-nightly-on-stable-hint')).toBeNull();
});
});
@@ -6,17 +6,28 @@ import {
ViewEncapsulation,
ChangeDetectionStrategy,
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { TranslateModule } from '@ngx-translate/core';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { UpdateChannelOption } from './settings.models';
@Component({
selector: 'app-settings-about-section',
imports: [MatButtonModule, MatIconModule, TranslateModule],
imports: [
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatSelectModule,
ReactiveFormsModule,
TranslateModule,
],
templateUrl: './settings-about-section.component.html',
encapsulation: ViewEncapsulation.None,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
@@ -24,6 +35,9 @@ import {
styles: [
':host { display: contents; }',
'.version-block .build-commit { opacity: 0.65; font-size: 0.85em; }',
'.app-update-channel { margin-top: 12px; }',
'.app-update-channel mat-form-field { width: 100%; max-width: 320px; }',
'.app-update-channel__note { display: block; margin-top: 4px; opacity: 0.75; font-size: 0.85em; }',
],
})
export class SettingsAboutSectionComponent {
@@ -34,6 +48,13 @@ export class SettingsAboutSectionComponent {
readonly appUpdateStatus = input<ElectronBridgeAppUpdateStatus | null>(
null
);
/**
* The shared settings form; the channel select lives in it so a change
* goes through the same Save and unsaved-changes flow as every other
* setting. Absent in hosts that only render the version block.
*/
readonly form = input<FormGroup | null>(null);
readonly updateChannelOptions = input<UpdateChannelOption[]>([]);
readonly buildCommitShort = computed(() => {
const commit = this.buildCommit()?.trim();
@@ -47,6 +68,23 @@ export class SettingsAboutSectionComponent {
readonly openManualAppUpdate = output<void>();
readonly openAppUpdateReleaseNotes = output<void>();
readonly canSelectUpdateChannel = computed(
() =>
this.isDesktop() &&
this.form() !== null &&
this.updateChannelOptions().length > 0
);
/** A nightly build on the stable channel waits for the next stable release. */
readonly isNightlyBuildOnStableChannel = computed(() => {
const status = this.appUpdateStatus();
return (
status?.installedChannel === 'nightly' &&
status.channel === 'stable'
);
});
readonly isAppUpdateBusy = computed(() => {
const status = this.appUpdateStatus()?.status;
@@ -82,6 +82,38 @@ describe('settings form utils — startup window mode', () => {
});
});
describe('settings form utils — update channel', () => {
const formBuilder = new FormBuilder();
it('defaults the form control to the stable channel', () => {
const form = createSettingsForm(formBuilder, true);
expect(form.getRawValue().updateChannel).toBe('stable');
});
it('carries the chosen channel into the settings object', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ updateChannel: 'nightly' });
expect(
createSettingsFromFormValue(form, {} as Settings).updateChannel
).toBe('nightly');
});
it('collapses a missing or unknown channel to stable', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ updateChannel: null as unknown as 'stable' });
expect(
createSettingsFromFormValue(form, {} as Settings).updateChannel
).toBe('stable');
form.patchValue({ updateChannel: 'canary' as unknown as 'stable' });
expect(
createSettingsFromFormValue(form, {} as Settings).updateChannel
).toBe('stable');
});
});
describe('settings form utils — EPG display offset', () => {
const formBuilder = new FormBuilder();
@@ -7,11 +7,14 @@ import {
Validators,
} from '@angular/forms';
import {
AppUpdateChannel,
CoverSize,
DEFAULT_APP_UPDATE_CHANNEL,
DEFAULT_DASHBOARD_RAILS_SETTINGS,
DEFAULT_TMDB_SETTINGS,
EpgViewMode,
Language,
normalizeAppUpdateChannel,
normalizeDashboardRailsSettings,
normalizeEmbeddedMpvExtraOptions,
normalizeExternalPlayerArguments,
@@ -89,6 +92,7 @@ export function createSettingsForm(
}),
startupBehavior: StartupBehavior.FirstView,
startupWindowMode: 'normal' as StartupWindowMode,
updateChannel: DEFAULT_APP_UPDATE_CHANNEL as AppUpdateChannel,
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
@@ -182,6 +186,7 @@ export function createSettingsFromFormValue(
dashboardRails: normalizeDashboardRailsSettings(value.dashboardRails),
startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView,
startupWindowMode: normalizeStartupWindowMode(value.startupWindowMode),
updateChannel: normalizeAppUpdateChannel(value.updateChannel),
showExternalPlaybackBar: value.showExternalPlaybackBar ?? true,
stripCountryPrefix: value.stripCountryPrefix ?? false,
theme: value.theme ?? Theme.SystemTheme,
@@ -13,6 +13,7 @@ import {
StartupBehaviorOption,
StartupWindowModeOption,
ThemeOption,
UpdateChannelOption,
} from './settings.models';
export const SETTINGS_THEME_OPTIONS: ThemeOption[] = [
@@ -90,6 +91,17 @@ export const SETTINGS_STARTUP_WINDOW_MODE_OPTIONS: StartupWindowModeOption[] = [
},
];
export const SETTINGS_UPDATE_CHANNEL_OPTIONS: UpdateChannelOption[] = [
{
value: 'stable',
labelKey: 'SETTINGS.APP_UPDATE_CHANNEL_STABLE',
},
{
value: 'nightly',
labelKey: 'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY',
},
];
export const SETTINGS_OS_PLAYER_OPTIONS: SettingsPlayerOption[] = [
{
id: VideoPlayer.MPV,
@@ -92,6 +92,8 @@
@case ('about') {
<app-settings-about-section
[isDesktop]="isDesktop"
[form]="settingsForm"
[updateChannelOptions]="updateChannelOptions"
[version]="appUpdate.version()"
[buildCommit]="buildCommit"
[updateMessage]="appUpdate.updateMessage()"
@@ -42,6 +42,7 @@ import {
SETTINGS_EPG_VIEW_MODE_OPTIONS,
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
SETTINGS_STARTUP_WINDOW_MODE_OPTIONS,
SETTINGS_UPDATE_CHANNEL_OPTIONS,
SETTINGS_THEME_OPTIONS,
} from './settings-options';
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
@@ -169,6 +170,7 @@ export class SettingsComponent
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
readonly startupWindowModeOptions = SETTINGS_STARTUP_WINDOW_MODE_OPTIONS;
readonly updateChannelOptions = SETTINGS_UPDATE_CHANNEL_OPTIONS;
readonly epgViewModeOptions = SETTINGS_EPG_VIEW_MODE_OPTIONS;
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({
@@ -1,4 +1,5 @@
import {
AppUpdateChannel,
CoverSize,
EpgViewMode,
StartupBehavior,
@@ -30,6 +31,11 @@ export interface StartupWindowModeOption {
labelKey: string;
}
export interface UpdateChannelOption {
value: AppUpdateChannel;
labelKey: string;
}
export interface CoverSizeOption {
value: CoverSize;
icon: string;
@@ -97,6 +97,7 @@ export const DEFAULT_SETTINGS = {
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
startupWindowMode: 'normal',
updateChannel: 'stable',
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
@@ -128,6 +129,8 @@ export const DEFAULT_APP_UPDATE_STATUS: ElectronBridgeAppUpdateStatus = {
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
supportedSelfUpdate: true,
channel: 'stable',
installedChannel: 'stable',
};
export class MatSnackBarStub {
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "إعادة تشغيل التطبيق",
"APP_UPDATE_OPEN_RELEASE": "فتح إصدار GitHub",
"APP_UPDATE_RELEASE_NOTES": "ما الجديد",
"APP_UPDATE_CHANNEL": "قناة التحديث",
"APP_UPDATE_CHANNEL_DESCRIPTION": "تتبع Stable الإصدارات المنشورة. تتبع Nightly كل دمج في master وتثبّت الإصدارات التجريبية تلقائيًا. يُطبَّق عند الحفظ.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "إصدارات Nightly هي لقطات غير مختبرة من master. قد تتعطل، وتغييراتها في قاعدة البيانات دائمة: عند العودة إلى Stable يبقى هذا الإصدار مثبتًا حتى يصدر إصدار مستقر أحدث. أنشئ نسخة احتياطية من قوائم التشغيل أولًا.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "أنت تستخدم إصدار Nightly. ستقترح قناة Stable الإصدار المستقر التالي بمجرد أن يصبح أحدث من هذا الإصدار.",
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "عاود شعل التطبيق",
"APP_UPDATE_OPEN_RELEASE": "حل إصدار GitHub",
"APP_UPDATE_RELEASE_NOTES": "شنو الجديد",
"APP_UPDATE_CHANNEL": "قناة التحديث",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable كتبع الإصدارات المنشورة. Nightly كتبع كل دمج ف master وكتثبّت الإصدارات التجريبية أوتوماتيكيا. كيتطبّق ملي تحفظ.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "إصدارات Nightly هي لقطات من master ما مجرّباش. يمكن يطيحو، والتغييرات ديالهم ف قاعدة البيانات دائمة: ملي ترجع ل Stable هاد الإصدار كيبقى مثبّت حتى يخرج إصدار مستقر جديد. دير نسخة احتياطية ديال قوائم التشغيل قبل.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "راك كتستعمل إصدار Nightly. قناة Stable غادي تقترح الإصدار المستقر الجاي ملي يولي جديد كثر من هاد الإصدار.",
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "Перазапусціць праграму",
"APP_UPDATE_OPEN_RELEASE": "Адкрыць рэліз GitHub",
"APP_UPDATE_RELEASE_NOTES": "Што новага",
"APP_UPDATE_CHANNEL": "Канал абнаўленняў",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable ідзе за выпушчанымі рэлізамі. Nightly ідзе за кожным зліццём у master і аўтаматычна ўсталёўвае папярэднія зборкі. Ужываецца пасля захавання.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly-зборкі — неправераныя здымкі master. Яны могуць працаваць нестабільна, а іх змены базы даных незваротныя: пасля вяртання на Stable гэтая зборка застанецца ўсталяванай, пакуль не выйдзе больш новы стабільны рэліз. Спачатку зрабіце рэзервовую копію плэйлістоў.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Вы выкарыстоўваеце nightly-зборку. Канал Stable прапануе наступны стабільны рэліз, як толькі ён стане навейшым за гэтую зборку.",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "Anwendung neu starten",
"APP_UPDATE_OPEN_RELEASE": "GitHub-Release öffnen",
"APP_UPDATE_RELEASE_NOTES": "Was ist neu",
"APP_UPDATE_CHANNEL": "Update-Kanal",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable folgt den veröffentlichten Releases. Nightly folgt jedem Merge in master und installiert Vorabversionen automatisch. Gilt nach dem Speichern.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly-Builds sind ungetestete Momentaufnahmen von master. Sie können fehlerhaft sein, und ihre Datenbankänderungen sind dauerhaft: Nach dem Wechsel zurück zu Stable bleibt dieser Build installiert, bis ein neueres stabiles Release erscheint. Sichern Sie zuerst Ihre Playlists.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Sie verwenden einen Nightly-Build. Der Stable-Kanal bietet das nächste stabile Release an, sobald es neuer als dieser Build ist.",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: Frame-Copy-Engine (experimentell)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Das Video wird auf die App-Leinwand gerendert, sodass Menüs und Dialoge darüber liegen.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wird nach einem Neustart der App wirksam.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "Επανεκκίνηση εφαρμογής",
"APP_UPDATE_OPEN_RELEASE": "Άνοιγμα GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Τι νέο υπάρχει",
"APP_UPDATE_CHANNEL": "Κανάλι ενημερώσεων",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Το Stable ακολουθεί τις δημοσιευμένες εκδόσεις. Το Nightly ακολουθεί κάθε συγχώνευση στο master και εγκαθιστά αυτόματα προκαταρκτικές εκδόσεις. Εφαρμόζεται κατά την αποθήκευση.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Οι εκδόσεις nightly είναι μη δοκιμασμένα στιγμιότυπα του master. Μπορεί να παρουσιάζουν σφάλματα και οι αλλαγές τους στη βάση δεδομένων είναι μόνιμες: αν επιστρέψετε στο Stable, αυτή η έκδοση παραμένει εγκατεστημένη μέχρι να υπάρξει νεότερη σταθερή έκδοση. Δημιουργήστε πρώτα αντίγραφο ασφαλείας των λιστών αναπαραγωγής.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Χρησιμοποιείτε έκδοση nightly. Το κανάλι Stable θα προσφέρει την επόμενη σταθερή έκδοση μόλις είναι νεότερη από αυτήν.",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"APP_UPDATE_CHANNEL": "Update channel",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable follows the tagged releases. Nightly follows every merge into master and installs prerelease builds automatically. Applies on Save.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly builds are untested snapshots of master. They may break, and their database changes are permanent: switching back to Stable keeps this build until the next stable release is newer. Back up your playlists first.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "You are running a nightly build. The Stable channel will offer the next stable release once it is newer than this build.",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy engine (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "Reiniciar aplicación",
"APP_UPDATE_OPEN_RELEASE": "Abrir release de GitHub",
"APP_UPDATE_RELEASE_NOTES": "Novedades",
"APP_UPDATE_CHANNEL": "Canal de actualizaciones",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable sigue las versiones publicadas. Nightly sigue cada fusión en master e instala compilaciones preliminares automáticamente. Se aplica al guardar.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Las compilaciones nightly son instantáneas sin probar de master. Pueden fallar y sus cambios en la base de datos son permanentes: al volver a Stable, esta compilación se mantiene instalada hasta que haya una versión estable más reciente. Haz primero una copia de seguridad de tus listas.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Estás usando una compilación nightly. El canal Stable ofrecerá la próxima versión estable en cuanto sea más reciente que esta compilación.",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "El vídeo se dibuja en el lienzo de la aplicación, de modo que los menús y diálogos quedan encima.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Se aplica tras reiniciar la aplicación.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "Redémarrer l’application",
"APP_UPDATE_OPEN_RELEASE": "Ouvrir la release GitHub",
"APP_UPDATE_RELEASE_NOTES": "Nouveautés",
"APP_UPDATE_CHANNEL": "Canal de mise à jour",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable suit les versions publiées. Nightly suit chaque fusion dans master et installe automatiquement les préversions. Appliqué à l’enregistrement.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Les builds nightly sont des instantanés non testés de master. Ils peuvent être instables et leurs modifications de la base de données sont définitives : en revenant à Stable, ce build reste installé jusqu’à ce qu’une version stable plus récente existe. Sauvegardez d’abord vos listes de lecture.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Vous utilisez un build nightly. Le canal Stable proposera la prochaine version stable dès qu’elle sera plus récente que ce build.",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV : moteur frame-copy (expérimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "La vidéo est rendue sur le canevas de l'application, les menus et boîtes de dialogue s'affichent donc au-dessus.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "Alkalmazás újraindítása",
"APP_UPDATE_OPEN_RELEASE": "GitHub-kiadás megnyitása",
"APP_UPDATE_RELEASE_NOTES": "Újdonságok",
"APP_UPDATE_CHANNEL": "Frissítési csatorna",
"APP_UPDATE_CHANNEL_DESCRIPTION": "A Stable a kiadott verziókat követi. A Nightly a master minden beolvasztását követi, és automatikusan telepíti az előzetes buildeket. Mentéskor lép érvénybe.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "A nightly buildek a master teszteletlen pillanatképei. Hibásak lehetnek, és adatbázis-módosításaik véglegesek: a Stable-re visszaváltva ez a build marad telepítve, amíg nem jelenik meg újabb stabil kiadás. Előbb készítsen biztonsági mentést a lejátszási listáiról.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Nightly buildet használ. A Stable csatorna a következő stabil kiadást kínálja fel, amint az újabb lesz ennél a buildnél.",
"EMBEDDED_MPV_FRAME_COPY": "Beágyazott MPV: képkocka-másolásos megjelenítés (kísérleti)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "A videó megjelenítése az alkalmazás rajzfelületén, hogy a menük és párbeszédpanelek fölötte jelenhessenek meg.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Az alkalmazás újraindítása után lép érvénybe.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "Riavvia applicazione",
"APP_UPDATE_OPEN_RELEASE": "Apri release GitHub",
"APP_UPDATE_RELEASE_NOTES": "Novità",
"APP_UPDATE_CHANNEL": "Canale di aggiornamento",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable segue le versioni pubblicate. Nightly segue ogni merge in master e installa automaticamente le build preliminari. Si applica al salvataggio.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Le build nightly sono istantanee non testate di master. Possono avere problemi e le loro modifiche al database sono permanenti: tornando a Stable, questa build resta installata finché non esiste una versione stabile più recente. Esegui prima un backup delle playlist.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Stai usando una build nightly. Il canale Stable proporrà la prossima versione stabile non appena sarà più recente di questa build.",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motore frame-copy (sperimentale)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Il video viene disegnato sul canvas dell'app, così menu e finestre di dialogo appaiono sopra di esso.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ha effetto dopo il riavvio dell'app.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "アプリを再起動",
"APP_UPDATE_OPEN_RELEASE": "GitHub リリースを開く",
"APP_UPDATE_RELEASE_NOTES": "更新内容",
"APP_UPDATE_CHANNEL": "更新チャンネル",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable は公開されたリリースに追従します。Nightly は master への各マージに追従し、プレリリースビルドを自動的にインストールします。保存時に適用されます。",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly ビルドは master の未検証スナップショットです。動作しない場合があり、データベースの変更は元に戻せません。Stable に戻しても、より新しい安定版がリリースされるまでこのビルドが残ります。先にプレイリストをバックアップしてください。",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Nightly ビルドを使用中です。Stable チャンネルは、このビルドより新しい安定版がリリースされ次第、それを提案します。",
"EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "애플리케이션 다시 시작",
"APP_UPDATE_OPEN_RELEASE": "GitHub 릴리스 열기",
"APP_UPDATE_RELEASE_NOTES": "새로운 내용",
"APP_UPDATE_CHANNEL": "업데이트 채널",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable은 정식 릴리스를 따릅니다. Nightly는 master에 병합될 때마다 따라가며 프리릴리스 빌드를 자동으로 설치합니다. 저장 시 적용됩니다.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly 빌드는 테스트되지 않은 master의 스냅샷입니다. 오류가 있을 수 있으며 데이터베이스 변경은 되돌릴 수 없습니다. Stable로 되돌려도 더 새로운 안정 릴리스가 나올 때까지 이 빌드가 유지됩니다. 먼저 재생목록을 백업하세요.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Nightly 빌드를 사용 중입니다. Stable 채널은 이 빌드보다 새로운 안정 릴리스가 나오면 그것을 제안합니다.",
"EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "Applicatie opnieuw starten",
"APP_UPDATE_OPEN_RELEASE": "GitHub-release openen",
"APP_UPDATE_RELEASE_NOTES": "Wat is nieuw",
"APP_UPDATE_CHANNEL": "Updatekanaal",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable volgt de uitgebrachte releases. Nightly volgt elke merge in master en installeert voorlopige builds automatisch. Wordt toegepast bij opslaan.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly-builds zijn ongeteste momentopnamen van master. Ze kunnen fouten bevatten en hun databasewijzigingen zijn permanent: na terugschakelen naar Stable blijft deze build geïnstalleerd tot er een nieuwere stabiele release is. Maak eerst een back-up van je afspeellijsten.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Je gebruikt een nightly-build. Het Stable-kanaal biedt de volgende stabiele release aan zodra die nieuwer is dan deze build.",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy-engine (experimenteel)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "De video wordt op het app-canvas getekend zodat menu's en dialoogvensters erboven verschijnen.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wordt van kracht na het herstarten van de app.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "Uruchom aplikację ponownie",
"APP_UPDATE_OPEN_RELEASE": "Otwórz wydanie w GitHub",
"APP_UPDATE_RELEASE_NOTES": "Co nowego",
"APP_UPDATE_CHANNEL": "Kanał aktualizacji",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable podąża za opublikowanymi wydaniami. Nightly podąża za każdym scaleniem do master i automatycznie instaluje wersje wstępne. Obowiązuje po zapisaniu.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Kompilacje nightly to nieprzetestowane migawki master. Mogą działać niestabilnie, a ich zmiany w bazie danych są trwałe: po powrocie na Stable ta kompilacja pozostanie zainstalowana, dopóki nie pojawi się nowsze wydanie stabilne. Najpierw wykonaj kopię zapasową playlist.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Używasz kompilacji nightly. Kanał Stable zaproponuje następne wydanie stabilne, gdy tylko będzie nowsze od tej kompilacji.",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: silnik frame-copy (eksperymentalny)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Wideo jest rysowane na kanwie aplikacji, dzięki czemu menu i okna dialogowe wyświetlają się nad nim.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "Reiniciar aplicativo",
"APP_UPDATE_OPEN_RELEASE": "Abrir release no GitHub",
"APP_UPDATE_RELEASE_NOTES": "Novidades",
"APP_UPDATE_CHANNEL": "Canal de atualização",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable segue as versões publicadas. Nightly segue cada merge em master e instala compilações preliminares automaticamente. Aplica-se ao salvar.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "As compilações nightly são instantâneos não testados de master. Podem falhar e suas alterações no banco de dados são permanentes: ao voltar para Stable, esta compilação permanece instalada até existir uma versão estável mais recente. Faça backup das suas playlists primeiro.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Você está usando uma compilação nightly. O canal Stable oferecerá a próxima versão estável assim que ela for mais recente que esta compilação.",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "O vídeo é desenhado na tela da aplicação, para que menus e diálogos fiquem por cima.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Entra em vigor após reiniciar a aplicação.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "Перезапустить приложение",
"APP_UPDATE_OPEN_RELEASE": "Открыть релиз на GitHub",
"APP_UPDATE_RELEASE_NOTES": "Что нового",
"APP_UPDATE_CHANNEL": "Канал обновлений",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable следует за выпущенными релизами. Nightly следует за каждым слиянием в master и автоматически устанавливает предварительные сборки. Применяется после сохранения.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly-сборки — непроверенные снимки master. Они могут работать нестабильно, а их изменения базы данных необратимы: после возврата на Stable эта сборка останется установленной, пока не выйдет более новый стабильный релиз. Сначала сделайте резервную копию плейлистов.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Вы используете nightly-сборку. Канал Stable предложит следующий стабильный релиз, как только он станет новее этой сборки.",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "Uygulamayı yeniden başlat",
"APP_UPDATE_OPEN_RELEASE": "GitHub sürümünü aç",
"APP_UPDATE_RELEASE_NOTES": "Yenilikler",
"APP_UPDATE_CHANNEL": "Güncelleme kanalı",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable, yayımlanan sürümleri izler. Nightly, master’a yapılan her birleştirmeyi izler ve ön sürümleri otomatik olarak kurar. Kaydedildiğinde uygulanır.",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly derlemeleri master’ın test edilmemiş anlık görüntüleridir. Bozuk olabilirler ve veritabanı değişiklikleri kalıcıdır: Stable’a geri dönüldüğünde bu derleme, daha yeni bir kararlı sürüm çıkana kadar kurulu kalır. Önce oynatma listelerinizi yedekleyin.",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "Bir nightly derlemesi kullanıyorsunuz. Stable kanalı, bu derlemeden daha yeni olduğunda sonraki kararlı sürümü sunacaktır.",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy motoru (deneysel)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Video uygulama tuvaline çizilir, böylece menüler ve iletişim kutuları üzerinde görüntülenir.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur.",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "重启应用",
"APP_UPDATE_OPEN_RELEASE": "打开 GitHub 发布页",
"APP_UPDATE_RELEASE_NOTES": "更新内容",
"APP_UPDATE_CHANNEL": "更新频道",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable 跟随正式发布的版本。Nightly 跟随每次合并到 master 的更改,并自动安装预发布构建。保存后生效。",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly 构建是 master 的未经测试快照。它们可能出错,且其数据库更改不可撤销:切换回 Stable 后,此构建会一直保留,直到出现更新的稳定版本。请先备份您的播放列表。",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "您正在使用 nightly 构建。Stable 频道会在下一个稳定版本比此构建更新时提供该版本。",
"EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。",
+6
View File
@@ -525,6 +525,12 @@
"APP_UPDATE_INSTALL": "重新啟動應用程式",
"APP_UPDATE_OPEN_RELEASE": "開啟 GitHub 發布頁",
"APP_UPDATE_RELEASE_NOTES": "更新內容",
"APP_UPDATE_CHANNEL": "更新頻道",
"APP_UPDATE_CHANNEL_DESCRIPTION": "Stable 跟隨正式發布的版本。Nightly 跟隨每次合併到 master 的變更,並自動安裝預先發布的建置版本。儲存後生效。",
"APP_UPDATE_CHANNEL_STABLE": "Stable",
"APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"APP_UPDATE_CHANNEL_NIGHTLY_WARNING": "Nightly 建置是 master 未經測試的快照。它們可能出錯,且其資料庫變更無法復原:切換回 Stable 後,此建置會一直保留,直到出現更新的穩定版本。請先備份您的播放清單。",
"APP_UPDATE_NIGHTLY_ON_STABLE_HINT": "您正在使用 nightly 建置。Stable 頻道會在下一個穩定版本比此建置更新時提供該版本。",
"EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。",