mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
merge: synchronize catch-up downloads after copy-URL squash merge
This commit is contained in:
commit
8da2631336
59 files changed
+1496
-47
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: migration
|
||||
---
|
||||
|
||||
Startup now shows a theme-aware preparation screen while loading sources and checking a large programme guide. Temporary source-read failures retry automatically; if loading still fails, you can retry without restarting the app. Recovery also completes any previously failed programme-guide cleanup before opening the library.
|
||||
@@ -122,6 +122,14 @@ retained, current payload rows are preserved, and completed imports never
|
||||
replay deleted sources. Contract and recovery limits:
|
||||
`docs/architecture/m3u-playlist-module.md` (Desktop upgrades from legacy profiles).
|
||||
|
||||
Startup shows `AppStartupStatusComponent` until the initial route and source
|
||||
inventory are ready, including XMLTV reconciliation. Inventory reads retry once;
|
||||
failed reads show an explicit Retry action instead of an empty library. Successful
|
||||
inventory reads first await settings loading, then pending XMLTV reconciliation,
|
||||
and retry failed cleanup
|
||||
with its last committed URLs before exposing the workspace. See the
|
||||
same contract for startup readiness and error handling.
|
||||
|
||||
## Electron Debugging (CDP)
|
||||
|
||||
- Start the Electron development app with: `nx serve electron-backend`
|
||||
|
||||
@@ -336,6 +336,14 @@ retained, current payload rows are preserved, and completed imports never
|
||||
replay deleted sources. Contract and recovery limits:
|
||||
`docs/architecture/m3u-playlist-module.md` (Desktop upgrades from legacy profiles).
|
||||
|
||||
Startup shows `AppStartupStatusComponent` until the initial route and source
|
||||
inventory are ready, including XMLTV reconciliation. Inventory reads retry once;
|
||||
failed reads show an explicit Retry action instead of an empty library. Successful
|
||||
inventory reads first await settings loading, then pending XMLTV reconciliation,
|
||||
and retry failed cleanup
|
||||
with its last committed URLs before exposing the workspace. See the
|
||||
same contract for startup readiness and error handling.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure (Nx Workspace)
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Page,
|
||||
} from '@playwright/test';
|
||||
import { join } from 'path';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises';
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
@@ -14,8 +14,18 @@ import {
|
||||
electronMainPath,
|
||||
workspaceRoot,
|
||||
openSources,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
} from './electron-test-fixtures';
|
||||
import { seedLegacyProfile, legacyPlaylists } from './legacy-profile-fixture';
|
||||
import { applyTheme } from './theme-contrast';
|
||||
|
||||
interface StartupTestGlobals {
|
||||
__failPlaylistReads: boolean;
|
||||
__deferRecoveredEpg: boolean;
|
||||
__releaseStartupEpg: () => void;
|
||||
__resolveLegacyRecoveryDialog: () => void;
|
||||
}
|
||||
|
||||
const migrationKey = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
const recoveryKey = 'playlists-electron-backend-profile-v1';
|
||||
@@ -56,12 +66,23 @@ async function sourceCount(page: Page) {
|
||||
async function launchWithRecoveryChoice(
|
||||
dataDir: string,
|
||||
response: number,
|
||||
retry = false
|
||||
retry = false,
|
||||
deferChoice = false,
|
||||
startupFault?: 'defer-epg' | 'fail-playlist-reads'
|
||||
) {
|
||||
const wrapper = join(dataDir, 'recovery-launch.cjs');
|
||||
await writeFile(
|
||||
wrapper,
|
||||
`const {dialog}=require('electron'); dialog.showMessageBox=async()=>({response:${response},checkboxChecked:false}); require(${JSON.stringify(electronMainPath)});`
|
||||
`const {dialog,ipcMain}=require('electron');
|
||||
dialog.showMessageBox=()=>new Promise(resolve=>{const choose=()=>resolve({response:${response},checkboxChecked:false}); ${deferChoice ? 'globalThis.__resolveLegacyRecoveryDialog=choose;' : 'choose();'}});
|
||||
globalThis.__failPlaylistReads=${startupFault === 'fail-playlist-reads'};
|
||||
const handle=ipcMain.handle.bind(ipcMain);
|
||||
ipcMain.handle=(channel,handler)=>handle(channel,async(...args)=>{
|
||||
if(channel==='DB_GET_APP_PLAYLIST_METAS' && globalThis.__failPlaylistReads) throw new Error('Synthetic playlist read failure');
|
||||
if(channel==='EPG_RECONCILE_SOURCES' && (${startupFault === 'defer-epg'} || globalThis.__deferRecoveredEpg)) await new Promise(resolve=>{globalThis.__releaseStartupEpg=resolve;});
|
||||
return handler(...args);
|
||||
});
|
||||
require(${JSON.stringify(electronMainPath)});`
|
||||
);
|
||||
const app = await electron.launch({
|
||||
args: [
|
||||
@@ -74,14 +95,258 @@ async function launchWithRecoveryChoice(
|
||||
env: buildElectronLaunchEnvironment(dataDir),
|
||||
});
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForSelector('app-root');
|
||||
await page.waitForFunction(
|
||||
() => typeof window.electron?.dbGetAppPlaylists === 'function'
|
||||
try {
|
||||
// The route is intentionally still empty while recovery is pending.
|
||||
await page.waitForSelector('app-root', { state: 'attached' });
|
||||
await page.waitForFunction(
|
||||
() => typeof window.electron?.dbGetAppPlaylists === 'function'
|
||||
);
|
||||
return { app, page };
|
||||
} catch (error) {
|
||||
await closeElectronApp({ electronApp: app, mainWindow: page });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Exercise the same failure after a real backup import clears a loaded store. */
|
||||
async function verifyBackupReloadRecovery(
|
||||
app: ElectronApplication,
|
||||
page: Page,
|
||||
dataDir: string
|
||||
) {
|
||||
const backupPath = join(dataDir, 'startup-retry-backup.json');
|
||||
await app.evaluate(({ dialog }, filePath) => {
|
||||
dialog.showSaveDialog = async () => ({ canceled: false, filePath });
|
||||
}, backupPath);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'backup');
|
||||
const backup = page.locator('#backup');
|
||||
await backup.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await expect(page.getByText('Playlist backup exported.')).toBeVisible();
|
||||
await app.evaluate(() => {
|
||||
(globalThis as typeof globalThis & StartupTestGlobals)[
|
||||
'__failPlaylistReads'
|
||||
] = true;
|
||||
});
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
await backup.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await (await chooser).setFiles(backupPath);
|
||||
const startup = page.locator('app-startup-status');
|
||||
await expect(startup.getByRole('alert')).toContainText(
|
||||
'Your sources could not be loaded'
|
||||
);
|
||||
return { app, page };
|
||||
await app.evaluate(() => {
|
||||
(globalThis as typeof globalThis & StartupTestGlobals)[
|
||||
'__failPlaylistReads'
|
||||
] = false;
|
||||
});
|
||||
await startup.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await openSources(page);
|
||||
await expect(
|
||||
page.getByText('Current synthetic source', { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(startup.locator('section')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe('v0.19 profile migration', () => {
|
||||
for (const response of [0, 1]) {
|
||||
test(`keeps current sources usable with a corrupt legacy profile: choice ${response}`, async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const initial = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await initial.mainWindow.evaluate(() =>
|
||||
window.electron.dbUpsertAppPlaylist({
|
||||
_id: 'current',
|
||||
title: 'Current synthetic source',
|
||||
count: 0,
|
||||
importDate: '2026-01-01',
|
||||
lastUsage: '2026-01-01',
|
||||
autoRefresh: false,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(initial);
|
||||
}
|
||||
const legacy = join(
|
||||
dataDir,
|
||||
'electron-backend',
|
||||
'IndexedDB',
|
||||
'file__0.indexeddb.leveldb'
|
||||
);
|
||||
await mkdir(legacy, { recursive: true });
|
||||
const marker = join(legacy, 'CURRENT');
|
||||
const corruptManifest = 'Synthetic invalid LevelDB manifest';
|
||||
await writeFile(marker, corruptManifest);
|
||||
const { app, page } = await launchWithRecoveryChoice(
|
||||
dataDir,
|
||||
response
|
||||
);
|
||||
try {
|
||||
await openSources(page);
|
||||
await expect(
|
||||
page.getByText('Current synthetic source', { exact: true })
|
||||
).toBeVisible();
|
||||
expect(await sourceCount(page)).toBe(1);
|
||||
expect(await readFile(marker, 'utf8')).toBe(corruptManifest);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(key) => window.electron.dbGetAppState(key),
|
||||
recoveryKey
|
||||
)
|
||||
).toBe(response === 0 ? 'declined' : null);
|
||||
} finally {
|
||||
await closeElectronApp({ electronApp: app, mainWindow: page });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const fault of ['defer-epg', 'fail-playlist-reads'] as const) {
|
||||
test(`keeps startup actionable after declining recovery: ${fault}`, async ({
|
||||
dataDir,
|
||||
}, testInfo) => {
|
||||
const initial = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await initial.mainWindow.evaluate(() =>
|
||||
window.electron.dbUpsertAppPlaylist({
|
||||
_id: 'current',
|
||||
title: 'Current synthetic source',
|
||||
count: 1,
|
||||
playlist: {
|
||||
header: { raw: '#EXTM3U' },
|
||||
items: [
|
||||
{
|
||||
id: 'synthetic-channel',
|
||||
raw: '#EXTINF:-1,Synthetic channel\nhttps://channel.invalid/live',
|
||||
name: 'Synthetic channel',
|
||||
url: 'https://channel.invalid/live',
|
||||
group: { title: 'Test' },
|
||||
},
|
||||
],
|
||||
},
|
||||
importDate: '2026-01-01',
|
||||
lastUsage: '2026-01-01',
|
||||
autoRefresh: false,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(initial);
|
||||
}
|
||||
await seedLegacyProfile(dataDir, 'm3u', false);
|
||||
const { app, page } = await launchWithRecoveryChoice(
|
||||
dataDir,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
fault
|
||||
);
|
||||
try {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => window.electron.dbGetAppState(key),
|
||||
recoveryKey
|
||||
)
|
||||
)
|
||||
.toBe('declined');
|
||||
const startup = page.locator('app-startup-status section');
|
||||
await expect(startup).toHaveCSS('app-region', 'drag');
|
||||
if (fault === 'defer-epg') {
|
||||
await expect(page.locator('#initial-splash')).toHaveCount(
|
||||
0
|
||||
);
|
||||
await expect(
|
||||
page.locator('app-startup-status').getByRole('status')
|
||||
).toContainText('Preparing your library');
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.evaluate(
|
||||
() =>
|
||||
typeof (
|
||||
globalThis as typeof globalThis &
|
||||
StartupTestGlobals
|
||||
)['__releaseStartupEpg'] === 'function'
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`startup-${theme}.png`),
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
await page.setViewportSize({ width: 480, height: 640 });
|
||||
await expect(page.locator('body')).toHaveJSProperty(
|
||||
'scrollWidth',
|
||||
480
|
||||
);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('startup-dark-narrow.png'),
|
||||
animations: 'disabled',
|
||||
});
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await app.evaluate(() =>
|
||||
(globalThis as typeof globalThis & StartupTestGlobals)[
|
||||
'__releaseStartupEpg'
|
||||
]()
|
||||
);
|
||||
} else {
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
'Your sources could not be loaded'
|
||||
);
|
||||
await expect(
|
||||
startup.getByRole('button', {
|
||||
name: 'Retry',
|
||||
exact: true,
|
||||
})
|
||||
).toHaveCSS('app-region', 'no-drag');
|
||||
await app.evaluate(() => {
|
||||
const hooks = globalThis as typeof globalThis &
|
||||
StartupTestGlobals;
|
||||
hooks.__failPlaylistReads = false;
|
||||
hooks.__deferRecoveredEpg = true;
|
||||
});
|
||||
await page
|
||||
.getByRole('button', { name: 'Retry', exact: true })
|
||||
.click();
|
||||
// Metadata is readable again, but startup must still wait
|
||||
// for the reconciliation that failed during settings load.
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.evaluate(
|
||||
() =>
|
||||
typeof (
|
||||
globalThis as typeof globalThis &
|
||||
StartupTestGlobals
|
||||
).__releaseStartupEpg
|
||||
)
|
||||
)
|
||||
.toBe('function');
|
||||
await expect(startup).toHaveAttribute('role', 'status');
|
||||
await app.evaluate(() => {
|
||||
const hooks = globalThis as typeof globalThis &
|
||||
StartupTestGlobals;
|
||||
hooks.__deferRecoveredEpg = false;
|
||||
hooks.__releaseStartupEpg();
|
||||
});
|
||||
}
|
||||
await openSources(page);
|
||||
await expect(
|
||||
page.getByText('Current synthetic source', { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('app-startup-status section')
|
||||
).toHaveCount(0);
|
||||
if (fault === 'fail-playlist-reads') {
|
||||
await verifyBackupReloadRecovery(app, page, dataDir);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp({ electronApp: app, mainWindow: page });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const active of ['xtream-2', 'stalker-60', 'm3u']) {
|
||||
test(`imports all 65 sources with ${active} last active, offline`, async ({
|
||||
dataDir,
|
||||
@@ -298,8 +563,27 @@ test.describe('v0.19 profile migration', () => {
|
||||
await closeElectronApp(initial);
|
||||
}
|
||||
await seedLegacyProfile(dataDir, 'xtream-2', false);
|
||||
let recovered = await launchWithRecoveryChoice(dataDir, 0);
|
||||
let recovered = await launchWithRecoveryChoice(dataDir, 0, false, true);
|
||||
try {
|
||||
// Leave the offer pending until renderer startup has reached it.
|
||||
// A synchronous stub plus raw DB reads cannot detect a blank route
|
||||
// after the real user eventually chooses Keep current sources.
|
||||
await expect
|
||||
.poll(() =>
|
||||
recovered.app.evaluate(
|
||||
() =>
|
||||
typeof (
|
||||
globalThis as typeof globalThis &
|
||||
StartupTestGlobals
|
||||
)['__resolveLegacyRecoveryDialog'] === 'function'
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
await recovered.app.evaluate(() =>
|
||||
(globalThis as typeof globalThis & StartupTestGlobals)[
|
||||
'__resolveLegacyRecoveryDialog'
|
||||
]()
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
recovered.page.evaluate(
|
||||
@@ -314,6 +598,12 @@ test.describe('v0.19 profile migration', () => {
|
||||
app.getPath('userData')
|
||||
)
|
||||
).toBe(join(dataDir, 'user-data'));
|
||||
await openSources(recovered.page);
|
||||
await expect(
|
||||
recovered.page.getByText('Current edited source', {
|
||||
exact: true,
|
||||
})
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp({
|
||||
electronApp: recovered.app,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MockStore, provideMockStore } from '@ngrx/store/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectPlaylistsLoadFailed,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { AppStartupStatusComponent } from './app-startup-status.component';
|
||||
|
||||
describe('AppStartupStatusComponent', () => {
|
||||
beforeEach(() =>
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AppStartupStatusComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
provideMockStore({
|
||||
selectors: [
|
||||
{ selector: selectPlaylistsLoadingFlag, value: false },
|
||||
{ selector: selectPlaylistsLoadFailed, value: false },
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
it('keeps showing preparation while EPG reconciliation delays the route', () => {
|
||||
const store = TestBed.inject(MockStore);
|
||||
store.overrideSelector(selectPlaylistsLoadingFlag, true);
|
||||
const fixture = TestBed.createComponent(AppStartupStatusComponent);
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[role="status"]')
|
||||
).not.toBeNull();
|
||||
fixture.componentRef.setInput('routeReady', true);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('section')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not expose an empty workspace before sources finish loading', () => {
|
||||
const fixture = TestBed.createComponent(AppStartupStatusComponent);
|
||||
fixture.componentRef.setInput('routeReady', true);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.complete()).toBe(false);
|
||||
const store = TestBed.inject(MockStore);
|
||||
store.overrideSelector(selectPlaylistsLoadingFlag, true);
|
||||
store.refreshState();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.complete()).toBe(true);
|
||||
});
|
||||
|
||||
it('offers a retry on failure without rendering raw storage errors', () => {
|
||||
TestBed.inject(MockStore).overrideSelector(
|
||||
selectPlaylistsLoadFailed,
|
||||
true
|
||||
);
|
||||
const fixture = TestBed.createComponent(AppStartupStatusComponent);
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[role="alert"]')
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('mat-spinner')).toBeNull();
|
||||
const dispatch = jest.spyOn(TestBed.inject(MockStore), 'dispatch');
|
||||
fixture.nativeElement.querySelector('button').click();
|
||||
expect(dispatch).toHaveBeenCalledWith(PlaylistActions.loadPlaylists());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectPlaylistsLoadFailed,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from '@iptvnator/m3u-state';
|
||||
|
||||
/** The first route also waits for settings and XMLTV source reconciliation. */
|
||||
@Component({
|
||||
selector: 'app-startup-status',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatButtonModule, MatProgressSpinnerModule, TranslatePipe],
|
||||
template: `
|
||||
@if (!complete()) {
|
||||
<section
|
||||
[attr.role]="failed() ? 'alert' : 'status'"
|
||||
aria-live="polite"
|
||||
>
|
||||
<svg
|
||||
class="startup-watermark"
|
||||
viewBox="0 0 256 256"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<g fill="none" stroke="currentColor" stroke-width="5">
|
||||
<rect x="46" y="53" width="166" height="115" rx="2" />
|
||||
<path d="M128 168v32M67 200h122" />
|
||||
<path
|
||||
d="M94 101Q129 72 165 101M101 109Q129 85 158 109M108 117Q129 99 151 117"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M119 126Q129 120 139 126L129 138Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
@if (!failed()) {
|
||||
<mat-spinner diameter="32" aria-hidden="true" />
|
||||
}
|
||||
<h1>
|
||||
{{
|
||||
(failed() ? 'STARTUP.FAILED' : 'STARTUP.PREPARING')
|
||||
| translate
|
||||
}}
|
||||
</h1>
|
||||
<p>
|
||||
{{
|
||||
(failed()
|
||||
? 'STARTUP.FAILED_DETAIL'
|
||||
: 'STARTUP.PREPARING_DETAIL'
|
||||
) | translate
|
||||
}}
|
||||
</p>
|
||||
@if (failed()) {
|
||||
<button mat-flat-button type="button" (click)="retry()">
|
||||
{{ 'RETRY' | translate }}
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
section {
|
||||
app-region: drag;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: clip;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
box-sizing: border-box;
|
||||
background: var(--app-content-bg);
|
||||
color: var(--app-heading-color);
|
||||
text-align: center;
|
||||
}
|
||||
.startup-watermark {
|
||||
position: absolute;
|
||||
right: -14%;
|
||||
bottom: -34%;
|
||||
width: clamp(420px, 76vw, 960px);
|
||||
height: auto;
|
||||
opacity: 0.07;
|
||||
transform: rotate(-14deg);
|
||||
mask-image: linear-gradient(135deg, transparent 15%, #000 78%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
section > :not(svg) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 420px;
|
||||
color: var(--app-body-color);
|
||||
line-height: 1.5;
|
||||
}
|
||||
button {
|
||||
margin-top: 8px;
|
||||
app-region: no-drag;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AppStartupStatusComponent {
|
||||
private readonly store = inject(Store);
|
||||
readonly routeReady = input(false);
|
||||
private readonly sourcesReady = this.store.selectSignal(
|
||||
selectPlaylistsLoadingFlag
|
||||
);
|
||||
readonly failed = this.store.selectSignal(selectPlaylistsLoadFailed);
|
||||
readonly complete = computed(
|
||||
() => this.routeReady() && this.sourcesReady()
|
||||
);
|
||||
|
||||
retry(): void {
|
||||
this.store.dispatch(PlaylistActions.loadPlaylists());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
<router-outlet />
|
||||
<app-startup-status #startup [routeReady]="routeReady()" />
|
||||
<div [style.display]="startup.complete() ? 'contents' : 'none'">
|
||||
<router-outlet (activate)="routeReady.set(true)" />
|
||||
</div>
|
||||
|
||||
<app-epg-progress-panel />
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Component, effect, HostBinding, inject, OnInit } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
HostBinding,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router, RouterOutlet } from '@angular/router';
|
||||
import { Actions, ofType } from '@ngrx/effects';
|
||||
@@ -34,6 +41,7 @@ import { SettingsService } from './services/settings.service';
|
||||
import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service';
|
||||
import { PlaylistOpenRequestService } from './services/playlist-open-request.service';
|
||||
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
|
||||
import { AppStartupStatusComponent } from './app-startup-status.component';
|
||||
|
||||
const debugAppComponent = createDevLogger('AppComponent');
|
||||
|
||||
@@ -41,6 +49,7 @@ const debugAppComponent = createDevLogger('AppComponent');
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
imports: [
|
||||
AppStartupStatusComponent,
|
||||
AppUpdateNotificationPanelComponent,
|
||||
EpgProgressPanelComponent,
|
||||
RouterOutlet,
|
||||
@@ -48,6 +57,7 @@ const debugAppComponent = createDevLogger('AppComponent');
|
||||
],
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
readonly routeReady = signal(false);
|
||||
@HostBinding('class.macos-platform') get isMacOS() {
|
||||
return this.runtime.isMacOS;
|
||||
}
|
||||
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "لا توجد بيانات للحساب",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "لا تنشر هذه البوابة معلومات الحساب لهذا الجهاز."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "جارٍ تجهيز المكتبة",
|
||||
"PREPARING_DETAIL": "جارٍ تحميل المصادر والتحقق من دليل البرامج. قد تستغرق المكتبات الكبيرة وقتًا أطول.",
|
||||
"FAILED": "تعذر تحميل المصادر",
|
||||
"FAILED_DETAIL": "أعد محاولة قراءة المصادر المحفوظة. لم تُحذف بياناتك."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "ما كايناش بيانات الحساب",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "هاد البوابة ما كتعطيش معلومات الحساب لهاد الجهاز."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "كنوجدو المكتبة ديالك",
|
||||
"PREPARING_DETAIL": "كنحمّلو المصادر وكنراجعو دليل البرامج. المكتبات الكبيرة تقدر تاخد وقت أكثر.",
|
||||
"FAILED": "ما قدرناش نحمّلو المصادر",
|
||||
"FAILED_DETAIL": "عاود جرّب تقرا المصادر المحفوظة. البيانات ديالك ما تمسحوش."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Няма даных пра акаўнт",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Гэты партал не дае інфармацыі пра акаўнт для гэтай прылады."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Падрыхтоўка бібліятэкі",
|
||||
"PREPARING_DETAIL": "Загружаем крыніцы і правяраем тэлепраграму. Вялікая бібліятэка можа патрабаваць больш часу.",
|
||||
"FAILED": "Не ўдалося загрузіць крыніцы",
|
||||
"FAILED_DETAIL": "Паўтарыце чытанне захаваных крыніц. Вашы даныя не выдалены."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Keine Kontodaten",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Dieses Portal stellt für dieses Gerät keine Kontoinformationen bereit."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Bibliothek wird vorbereitet",
|
||||
"PREPARING_DETAIL": "Quellen werden geladen und der Programmführer geprüft. Große Bibliotheken können etwas länger dauern.",
|
||||
"FAILED": "Quellen konnten nicht geladen werden",
|
||||
"FAILED_DETAIL": "Versuche erneut, die gespeicherten Quellen zu lesen. Deine Daten wurden nicht gelöscht."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Δεν υπάρχουν στοιχεία λογαριασμού",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Αυτή η πύλη δεν δημοσιεύει πληροφορίες λογαριασμού για αυτήν τη συσκευή."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Προετοιμασία βιβλιοθήκης",
|
||||
"PREPARING_DETAIL": "Φόρτωση πηγών και έλεγχος του οδηγού προγράμματος. Οι μεγάλες βιβλιοθήκες μπορεί να χρειαστούν περισσότερο χρόνο.",
|
||||
"FAILED": "Δεν ήταν δυνατή η φόρτωση των πηγών",
|
||||
"FAILED_DETAIL": "Δοκιμάστε ξανά την ανάγνωση των αποθηκευμένων πηγών. Τα δεδομένα σας δεν διαγράφηκαν."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "No account details",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "This portal does not publish account information for this device."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Preparing your library",
|
||||
"PREPARING_DETAIL": "Loading your sources and checking the programme guide. Large libraries may take a little longer.",
|
||||
"FAILED": "Your sources could not be loaded",
|
||||
"FAILED_DETAIL": "Retry to read your saved sources again. Your data has not been deleted."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Sin datos de la cuenta",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Este portal no publica información de la cuenta para este dispositivo."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Preparando tu biblioteca",
|
||||
"PREPARING_DETAIL": "Cargando fuentes y comprobando la guía de programación. Las bibliotecas grandes pueden tardar un poco más.",
|
||||
"FAILED": "No se pudieron cargar tus fuentes",
|
||||
"FAILED_DETAIL": "Vuelve a intentar leer las fuentes guardadas. Tus datos no se han eliminado."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Aucune donnée de compte",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Ce portail ne publie pas d’informations de compte pour cet appareil."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Préparation de la bibliothèque",
|
||||
"PREPARING_DETAIL": "Chargement des sources et vérification du guide TV. Les grandes bibliothèques peuvent prendre plus de temps.",
|
||||
"FAILED": "Impossible de charger vos sources",
|
||||
"FAILED_DETAIL": "Réessayez de lire les sources enregistrées. Vos données n’ont pas été supprimées."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Nincsenek fiókadatok",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Ez a portál nem tesz közzé fiókadatokat ehhez az eszközhöz."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Könyvtár előkészítése",
|
||||
"PREPARING_DETAIL": "Források betöltése és a műsorújság ellenőrzése. Nagy könyvtárak esetén ez tovább tarthat.",
|
||||
"FAILED": "Nem sikerült betölteni a forrásokat",
|
||||
"FAILED_DETAIL": "Próbáld újra beolvasni a mentett forrásokat. Az adataid nem lettek törölve."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Nessun dato account",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Questo portale non pubblica informazioni sull’account per questo dispositivo."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Preparazione della libreria",
|
||||
"PREPARING_DETAIL": "Caricamento delle sorgenti e verifica della guida TV. Le librerie grandi possono richiedere più tempo.",
|
||||
"FAILED": "Impossibile caricare le sorgenti",
|
||||
"FAILED_DETAIL": "Riprova a leggere le sorgenti salvate. I tuoi dati non sono stati eliminati."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "アカウント情報なし",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "このポータルは、このデバイスのアカウント情報を公開していません。"
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "ライブラリを準備しています",
|
||||
"PREPARING_DETAIL": "ソースを読み込み、番組表を確認しています。大きなライブラリでは時間がかかる場合があります。",
|
||||
"FAILED": "ソースを読み込めませんでした",
|
||||
"FAILED_DETAIL": "保存したソースの読み込みを再試行してください。データは削除されていません。"
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "계정 정보 없음",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "이 포털은 이 기기에 대한 계정 정보를 제공하지 않습니다."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "라이브러리 준비 중",
|
||||
"PREPARING_DETAIL": "소스를 불러오고 편성표를 확인하고 있습니다. 라이브러리가 크면 시간이 더 걸릴 수 있습니다.",
|
||||
"FAILED": "소스를 불러오지 못했습니다",
|
||||
"FAILED_DETAIL": "저장된 소스를 다시 읽어 보세요. 데이터는 삭제되지 않았습니다."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Geen accountgegevens",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Dit portaal publiceert geen accountinformatie voor dit apparaat."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Bibliotheek voorbereiden",
|
||||
"PREPARING_DETAIL": "Bronnen laden en de programmagids controleren. Grote bibliotheken kunnen wat meer tijd kosten.",
|
||||
"FAILED": "Je bronnen konden niet worden geladen",
|
||||
"FAILED_DETAIL": "Probeer je opgeslagen bronnen opnieuw te lezen. Je gegevens zijn niet verwijderd."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Brak danych konta",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Ten portal nie udostępnia informacji o koncie dla tego urządzenia."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Przygotowywanie biblioteki",
|
||||
"PREPARING_DETAIL": "Wczytywanie źródeł i sprawdzanie programu TV. Duże biblioteki mogą wymagać więcej czasu.",
|
||||
"FAILED": "Nie udało się wczytać źródeł",
|
||||
"FAILED_DETAIL": "Spróbuj ponownie odczytać zapisane źródła. Twoje dane nie zostały usunięte."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Sem dados da conta",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Este portal não publica informações da conta para este dispositivo."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "A preparar a biblioteca",
|
||||
"PREPARING_DETAIL": "A carregar fontes e verificar o guia de programação. Bibliotecas grandes podem demorar mais.",
|
||||
"FAILED": "Não foi possível carregar as fontes",
|
||||
"FAILED_DETAIL": "Tente ler novamente as fontes guardadas. Os seus dados não foram eliminados."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Нет данных об аккаунте",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Этот портал не предоставляет информацию об аккаунте для этого устройства."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Подготовка библиотеки",
|
||||
"PREPARING_DETAIL": "Загружаем источники и проверяем телепрограмму. Большая библиотека может потребовать больше времени.",
|
||||
"FAILED": "Не удалось загрузить источники",
|
||||
"FAILED_DETAIL": "Повторите чтение сохранённых источников. Ваши данные не удалены."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Hesap verisi yok",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Bu portal, bu cihaz için hesap bilgisi yayınlamıyor."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Kitaplığınız hazırlanıyor",
|
||||
"PREPARING_DETAIL": "Kaynaklar yükleniyor ve program rehberi kontrol ediliyor. Büyük kitaplıklar biraz daha uzun sürebilir.",
|
||||
"FAILED": "Kaynaklarınız yüklenemedi",
|
||||
"FAILED_DETAIL": "Kayıtlı kaynaklarınızı okumayı yeniden deneyin. Verileriniz silinmedi."
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "无账户数据",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "该门户不为此设备提供账户信息。"
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "正在准备媒体库",
|
||||
"PREPARING_DETAIL": "正在加载来源并检查节目指南。较大的媒体库可能需要更长时间。",
|
||||
"FAILED": "无法加载来源",
|
||||
"FAILED_DETAIL": "请重试读取已保存的来源。您的数据未被删除。"
|
||||
}
|
||||
}
|
||||
@@ -1730,5 +1730,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "沒有帳戶資料",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "此入口網站不提供此裝置的帳戶資訊。"
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "正在準備媒體庫",
|
||||
"PREPARING_DETAIL": "正在載入來源並檢查節目指南。較大的媒體庫可能需要更長時間。",
|
||||
"FAILED": "無法載入來源",
|
||||
"FAILED_DETAIL": "請重試讀取已儲存的來源。您的資料未被刪除。"
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,8 @@ skip shows up in your output.
|
||||
Evergreen how-to posts live in the blog collection next to release notes
|
||||
(`xtream-codes-setup-guide.mdx`, `stalker-portal-setup-guide.mdx`,
|
||||
`m3u-playlist-epg-setup-guide.mdx`, `offline-downloads-guide.mdx`,
|
||||
`alternative-sources-guide.mdx` and `remote-control-guide.mdx` in
|
||||
`alternative-sources-guide.mdx`, `remote-control-guide.mdx` and
|
||||
`epg-wrong-program-fix.mdx` in
|
||||
`apps/website/src/content/blog/`). Three conventions set them apart:
|
||||
|
||||
- **`ContentDisclaimer`.** Every guide opens with
|
||||
@@ -119,6 +120,10 @@ Evergreen how-to posts live in the blog collection next to release notes
|
||||
`tools/release/capture-release-screenshots.ts`). Its setup selects a live
|
||||
channel and saves the remote-control setting, so the app's own server answers
|
||||
on port 8765 for the duration of the run.
|
||||
The EPG-mapping shots import the mock's XMLTV guide (`/demo/guide.xml`,
|
||||
channel ids that match no playlist `tvg-id` on purpose) into the isolated
|
||||
database through the settings, then right-click a channel of the M3U
|
||||
fixture and search the dialog.
|
||||
|
||||
`tools/testing/website-guides.test.mjs` (part of `pnpm nx test website`) checks
|
||||
each guide for the FAQPage schema, a link to the download hub and the presence
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
@@ -21,6 +21,7 @@
|
||||
- Guide, offline viewing with the download manager (desktop): https://4gray.github.io/iptvnator/blog/offline-downloads-guide/
|
||||
- Guide, alternative sources for a movie across your own playlists (desktop): https://4gray.github.io/iptvnator/blog/alternative-sources-guide/
|
||||
- Guide, phone remote control setup (desktop): https://4gray.github.io/iptvnator/blog/remote-control-guide/
|
||||
- Troubleshooting, EPG shows the wrong program or nothing (channel mapping, time offset): https://4gray.github.io/iptvnator/blog/epg-wrong-program-fix/
|
||||
- Features overview: https://4gray.github.io/iptvnator/features/
|
||||
- Feature, M3U playlist player: https://4gray.github.io/iptvnator/features/m3u-player/
|
||||
- Feature, Xtream Codes player: https://4gray.github.io/iptvnator/features/xtream-codes-player/
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
title: EPG Shows the Wrong Program or Nothing? Fix Channel Mapping and Time Offset in IPTVnator
|
||||
description: When the program guide is empty for a channel, shows another channel's schedule, or is off by a few hours, the fix is one of three things. How IPTVnator matches guide data to channels, how to map a channel by hand, and how to correct the time offset without touching the guide.
|
||||
pubDate: 2026-09-06
|
||||
author: 4gray
|
||||
heroImage: /iptvnator/blog/guides/screenshots/guide-epg-map-dialog-dark.png
|
||||
tags:
|
||||
- troubleshooting
|
||||
- epg
|
||||
draft: false
|
||||
faq:
|
||||
- q: Why does one channel have no program information while the others do?
|
||||
a: The guide has no entry the app can attach to that channel. IPTVnator matches an XMLTV channel by the playlist's tvg-id, then by tvg-name, then by the channel name; when none of them matches an id or display name in the guide, the row stays empty. Map the channel by hand with a right-click and "Map EPG channel".
|
||||
- q: Where is the mapping stored, and does it survive a playlist refresh?
|
||||
a: On your device, in the local database of the desktop app, keyed by the channel, not by the playlist file. Refreshing the playlist or the guide keeps it. "Remove mapping" in the same dialog deletes it.
|
||||
- q: Every program is shown a few hours early or late. Is the guide broken?
|
||||
a: Usually the guide labels its times with a timezone that does not match the broadcast, or your provider's clock is off. Settings › EPG › EPG time offset shifts every displayed time by a number of minutes without refreshing the guide. Use a negative value when programs appear too late and a positive value when they appear too early.
|
||||
- q: Does the offset change catch-up or recordings?
|
||||
a: No. It only changes what is displayed. Catch-up requests, recording snapshots and the stored guide keep the provider's own times, so the offset can never break playback.
|
||||
- q: Can I map channels from an Xtream or Stalker portal?
|
||||
a: Yes, in the desktop app. Portals bring their own guide, but you can attach an uploaded XMLTV channel to any live channel by right-clicking it in the channel list. For Xtream sources, the setting "Prefer your EPG sources for Xtream playlists" makes the uploaded guide win over the provider's whenever both exist.
|
||||
- q: Does the browser version support mapping and the offset?
|
||||
a: No. Both belong to the desktop app, which is where XMLTV guides are downloaded and stored. The browser version shows the guide a portal provides and nothing else.
|
||||
- q: The mapping dialog finds no channels at all. Why?
|
||||
a: No guide is loaded yet, or the guide has not finished importing. Add the XMLTV source in Settings › EPG, fetch it, and wait for the import to finish before you search. The search needs at least two characters.
|
||||
---
|
||||
|
||||
import Alert from '../../components/blog/Alert.astro';
|
||||
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
|
||||
import PostButton from '../../components/blog/PostButton.astro';
|
||||
import StepRail from '../../components/blog/StepRail.astro';
|
||||
import LinkCards from '../../components/blog/LinkCards.astro';
|
||||
|
||||
The guide is loaded, most channels show what is on, and then one row says *No program
|
||||
information available*, another shows the schedule of a different station, and the evening
|
||||
news appears at two in the afternoon. All three are common, all three have one specific
|
||||
cause, and none of them needs a new playlist.
|
||||
|
||||
This post explains how IPTVnator decides which guide entry belongs to which channel, how to
|
||||
map a channel by hand when that decision goes wrong, and how to correct times that are off by
|
||||
a fixed amount. It applies to the desktop app on Windows, macOS and Linux, where XMLTV guides
|
||||
are downloaded and stored.
|
||||
|
||||
<ContentDisclaimer />
|
||||
|
||||
## Which of the three problems you have
|
||||
|
||||
| What you see | Cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| No program information for one or a few channels | No guide entry could be attached to the channel | Map the channel by hand |
|
||||
| A channel shows another channel's schedule | The playlist's id points at the wrong guide entry | Map the channel by hand |
|
||||
| Every program is early or late by the same amount | The guide's times carry the wrong timezone | Set the EPG time offset |
|
||||
| Everything is empty | The guide did not load at all | Check the source in Settings › EPG |
|
||||
|
||||
The last case is covered in the [M3U and EPG setup guide](/iptvnator/blog/m3u-playlist-epg-setup-guide/);
|
||||
this post is about the first three.
|
||||
|
||||
## How a channel finds its guide entry
|
||||
|
||||
An XMLTV guide lists channels with an id and a display name, and programs that refer to the
|
||||
id. When the desktop app looks for the guide of a playlist channel, it tries, in this order:
|
||||
|
||||
1. the playlist's `tvg-id` against the guide's channel id,
|
||||
2. the playlist's `tvg-name` against the guide's display names,
|
||||
3. the channel name itself against the display names.
|
||||
|
||||
When the playlist carries ids from a different guide than the one you loaded, step one fails,
|
||||
and steps two and three only succeed when the names happen to be identical. "Aurora News HD"
|
||||
in the playlist and "Aurora News" in the guide are different names to the matcher. That is the
|
||||
empty row. A playlist whose ids are simply wrong gives the second symptom: the id exists in the
|
||||
guide, but belongs to another station.
|
||||
|
||||
## Map a channel by hand
|
||||
|
||||
<StepRail
|
||||
title="Attach a guide entry to a channel"
|
||||
steps={[
|
||||
'Right-click the channel in the channel list. The context menu works in the M3U player, in the live list of an Xtream or Stalker source, and in the global favorites.',
|
||||
'Choose "Map EPG channel".',
|
||||
'Type part of the station name. The dialog searches the channel names of every guide you loaded and lists the matches after two characters; each result also shows the guide id.',
|
||||
'Pick the right entry and click "Save mapping". The row shows the current program right away.',
|
||||
]}
|
||||
/>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
A mapping belongs to the channel, not to the playlist file. Refreshing the playlist, refreshing
|
||||
the guide or restarting the app keeps it. Open the same dialog to change it; **Remove mapping**
|
||||
returns the channel to automatic matching.
|
||||
|
||||
<Alert type="info" title="Portals have their own guide">
|
||||
Xtream and Stalker sources receive program data from the provider and do not need an XMLTV
|
||||
file. Mapping is still available for them: it attaches an uploaded guide entry to a portal
|
||||
channel whose provider data is missing or wrong. For Xtream, the setting "Prefer your EPG
|
||||
sources for Xtream playlists" in Settings › EPG decides which guide wins when both exist.
|
||||
</Alert>
|
||||
|
||||
## Correct times that are off by hours
|
||||
|
||||
If every program is shifted by the same amount, the guide is fine and only its clock is not.
|
||||
Many providers label their XMLTV times with the wrong timezone, and a few servers run on a
|
||||
clock that is simply off. Open **Settings › EPG › EPG time offset** and enter the correction
|
||||
in minutes, from −720 to 720. The change applies immediately to every guide view, the channel
|
||||
list, the dashboard and the timeline, for every source.
|
||||
|
||||

|
||||
|
||||
Which sign to use:
|
||||
|
||||
- Programs appear **too early** (the news that starts at 20:00 is shown at 18:00): the guide is
|
||||
behind; enter a **positive** value, here `120`.
|
||||
- Programs appear **too late**: enter a **negative** value.
|
||||
|
||||
The offset changes only what you see. The guide stays stored as delivered, catch-up requests
|
||||
and recording snapshots keep the provider's own times, and a refresh of the guide does not
|
||||
reset the setting.
|
||||
|
||||
<Alert type="warning" title="Check it twice a year">
|
||||
A guide that carries the wrong timezone is usually wrong by a different amount after a
|
||||
daylight-saving change. If programs drift by an hour in spring or autumn, adjust the offset
|
||||
rather than the source.
|
||||
</Alert>
|
||||
|
||||
## When it is the source after all
|
||||
|
||||
Mapping and the offset fix a guide that is present but attached or timed wrongly. They cannot
|
||||
fix a guide that does not carry the station at all. If the search in the mapping dialog finds
|
||||
nothing sensible, the loaded XMLTV covers a different region or provider than your playlist.
|
||||
Add a guide that matches the channels, or ask the provider for the URL of theirs; playlists
|
||||
often carry it in the `url-tvg` attribute of the first line, and the desktop app offers to
|
||||
import those automatically.
|
||||
|
||||
## Related
|
||||
|
||||
<LinkCards
|
||||
links={[
|
||||
{ label: 'Load an M3U playlist and add an EPG', href: '/iptvnator/blog/m3u-playlist-epg-setup-guide/', hint: 'Adding the guide source in the first place, and why a guide does not load.', icon: 'docs' },
|
||||
{ label: 'Understanding the Electronic Program Guide', href: '/iptvnator/blog/epg-guide/', hint: 'What XMLTV is and what the guide can show.', icon: 'docs' },
|
||||
{ label: 'TV guide (EPG) feature page', href: '/iptvnator/features/epg/', hint: 'Timeline, list view, multi-channel guide and catch-up in one place.', icon: 'docs' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PostButton href="/iptvnator/download/" label="Get IPTVnator for your platform" external={false} />
|
||||
@@ -233,6 +233,7 @@ application code.
|
||||
- **Dedicated EPG fixture**: `epg:epg` returns stable live channels plus deterministic `get_short_epg` and `get_simple_data_table` payloads for timezone-focused tests
|
||||
- **Unusable timezone name**: `tzoffset:tzoffset` serves the same EPG fixture behind a panel whose `server_info.timezone` is the ICU-unknown spelling `UTC+3`, while its `time_now` / `timestamp_now` clock pair reveals a +03:00 offset — the catch-up URL must be derived from the clock pair (issue #1562)
|
||||
- **Release screenshot fixture**: `marketing:marketing` returns fictional live, VOD, and series data with local generated artwork under `apps/xtream-mock-server/public/marketing`
|
||||
- **Demo XMLTV guide**: `GET /demo/guide.xml` returns an XMLTV file for the marketing live channels (ids such as `aurora-news.fictional`, eight half-hour programmes around now, logos from the local asset route), so the desktop app can import an EPG source hermetically; guide screenshots use it for the manual channel-mapping flow
|
||||
- **Alternative-source fixture**: `marketing2:marketing2` returns the identical marketing catalog under a second credential pair, so a movie added from both looks like the same film in two playlists (the premise of the VOD multi-source chip); guide screenshots seed it as a "backup subscription"
|
||||
- **Local download media**: `marketing:marketing` also serves `/movie/...`, `/series/...` and `/live/...` stream URLs from generated bytes (`downloadStreamFixture: 'local-media'`; movies finish in under a second, episodes trickle for about 20 s) so release and guide screenshots of the download manager complete without any request leaving the machine. Other scenarios keep redirecting streams to the public HLS stub.
|
||||
- **Performance fixture**: `performance:performance` returns exactly 100,000
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { MARKETING_LIVE_CHANNELS } from '@iptvnator/shared/marketing-fixtures';
|
||||
|
||||
/**
|
||||
* `/demo/guide.xml`: an XMLTV guide for the marketing live channels, so the
|
||||
* desktop app can import an EPG source that never leaves the machine. The
|
||||
* channel ids deliberately differ from any playlist's `tvg-id`, which is what
|
||||
* makes the manual "Map EPG channel" flow worth showing in guide screenshots.
|
||||
*/
|
||||
export function renderDemoXmltv(origin: string, now = new Date()): string {
|
||||
const slotMs = 30 * 60 * 1000;
|
||||
const roundedNow = now.getTime() - (now.getTime() % slotMs);
|
||||
const channels = MARKETING_LIVE_CHANNELS.map((channel) => ({
|
||||
id: `${slugify(channel.name)}.fictional`,
|
||||
name: channel.name,
|
||||
icon: `${origin}/assets/marketing/logo/${slugify(channel.name)}.svg?size=256x256`,
|
||||
titles: channel.epgTitles,
|
||||
}));
|
||||
const lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<!DOCTYPE tv SYSTEM "xmltv.dtd">',
|
||||
'<tv generator-info-name="iptvnator-xtream-mock-server">',
|
||||
];
|
||||
|
||||
for (const channel of channels) {
|
||||
lines.push(
|
||||
` <channel id="${escapeXml(channel.id)}">`,
|
||||
` <display-name>${escapeXml(channel.name)}</display-name>`,
|
||||
` <icon src="${escapeXml(channel.icon)}" />`,
|
||||
' </channel>'
|
||||
);
|
||||
}
|
||||
|
||||
for (const channel of channels) {
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const title = channel.titles[index % channel.titles.length];
|
||||
const start = roundedNow + (index - 2) * slotMs;
|
||||
lines.push(
|
||||
` <programme start="${formatXmltvDate(start)}" stop="${formatXmltvDate(start + slotMs)}" channel="${escapeXml(channel.id)}">`,
|
||||
` <title>${escapeXml(title)}</title>`,
|
||||
` <desc>${escapeXml(`${title} on ${channel.name}, part of the fictional IPTVnator demo schedule.`)}</desc>`,
|
||||
' </programme>'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('</tv>', '');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function formatXmltvDate(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return (
|
||||
`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
|
||||
`${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())} +0000`
|
||||
);
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -147,6 +147,26 @@ describe('Xtream mock server factory', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('serves an XMLTV guide for the marketing live channels at /demo/guide.xml', async () => {
|
||||
const running = await startLoopbackServer(
|
||||
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
|
||||
);
|
||||
try {
|
||||
const response = await fetch(`${running.origin}/demo/guide.xml`);
|
||||
const xml = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('xml');
|
||||
expect(xml).toContain('<tv ');
|
||||
expect(xml).toContain('<display-name>Aurora News</display-name>');
|
||||
expect(xml).toContain('channel="aurora-news.fictional"');
|
||||
expect(xml).toContain(`${running.origin}/assets/marketing/logo/aurora-news.svg`);
|
||||
expect(xml).not.toMatch(/https?:\/\/(?!127\.0\.0\.1|localhost)/);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('serves the marketing catalog twice so the second credential pair is an alternative source', async () => {
|
||||
const running = await startLoopbackServer(
|
||||
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './performance-control.js';
|
||||
import { dispatchAction } from './routes/dispatch.js';
|
||||
import { getScenario, type ScenarioConfig } from './scenarios.js';
|
||||
import { renderDemoXmltv } from './demo-xmltv.js';
|
||||
import {
|
||||
LOCAL_MEDIA_EPISODE_DOWNLOAD_OPTIONS,
|
||||
LOCAL_MEDIA_LIVE_STREAM_OPTIONS,
|
||||
@@ -107,6 +108,7 @@ export function createXtreamMockApp(
|
||||
.send(M3U_FIXTURE);
|
||||
});
|
||||
installMarketingAssetRoute(app);
|
||||
installDemoGuideRoute(app);
|
||||
|
||||
app.get('/player_api.php', (request, response, next) => {
|
||||
dispatchWithControl(controller, request, response, next, 'direct', () =>
|
||||
@@ -214,6 +216,17 @@ function dispatchProxyAction(request: Request, response: Response): void {
|
||||
response.json({ payload, action });
|
||||
}
|
||||
|
||||
/** XMLTV for the marketing live channels; see `demo-xmltv.ts`. */
|
||||
function installDemoGuideRoute(app: express.Express): void {
|
||||
app.get('/demo/guide.xml', (request, response) => {
|
||||
const origin = `${request.protocol}://${request.get('host') ?? `localhost:${DEFAULT_PORT}`}`;
|
||||
response
|
||||
.type('application/xml')
|
||||
.set('Cache-Control', 'no-store')
|
||||
.send(renderDemoXmltv(origin));
|
||||
});
|
||||
}
|
||||
|
||||
function installMarketingAssetRoute(app: express.Express): void {
|
||||
app.get('/assets/marketing/:kind/:slug', (request, response) => {
|
||||
const kind = request.params['kind'] as
|
||||
|
||||
@@ -76,12 +76,42 @@ migrations. In particular, `idx_content_epg_channel` must follow the addition of
|
||||
`content.epg_channel_id`; creating it in the initial CREATE TABLE pass aborts
|
||||
initialization on the v0.19 schema before the playlist migration can run.
|
||||
|
||||
Initial workspace routing waits for settings and XMLTV source reconciliation.
|
||||
Removing a large obsolete EPG cache can therefore delay the first view even
|
||||
after legacy recovery is declined. `AppStartupStatusComponent` shows preparation
|
||||
until both the initial routed component and source inventory are ready; routed
|
||||
content stays hidden from pointer and keyboard interaction during that interval.
|
||||
There is no estimated percentage or timeout that bypasses reconciliation.
|
||||
|
||||
Source inventory reads recreate the storage request for one automatic retry
|
||||
after 300 ms. Exhausted retries emit `Load Playlists Failure` without raw error
|
||||
details and leave `allPlaylistsLoaded` false. The startup error offers Retry,
|
||||
which dispatches a fresh load without restarting or changing saved sources.
|
||||
Before publishing a successful inventory, the effect waits for settings loading
|
||||
to register initial cleanup, then waits for any active EPG reconciliation and retries the last failed synchronization using its committed,
|
||||
normalized global URLs. A settings-read failure never supplies defaults to this
|
||||
retry; a newer successful synchronization clears the failure instead of replaying
|
||||
an older source set. Repeated cleanup failure leaves Retry available.
|
||||
Every inventory load clears readiness, including reloads after backup import,
|
||||
so a later read failure also exposes Retry. A successfully loaded empty inventory
|
||||
is distinct from a failed read. The preparation/error surface remains a native
|
||||
window drag region, with Retry excluded so it stays clickable. Its static,
|
||||
monochrome SVG watermark inherits the theme heading color, fades toward the
|
||||
content, and is clipped, pointer-transparent and hidden from assistive technology.
|
||||
|
||||
Validation: `electron-backend-e2e:e2e-ci--src/legacy-playlist-migration.e2e.ts`
|
||||
seeds the exact v0.19 IndexedDB schema and verbatim SQL CREATE statements with
|
||||
61 Stalker sources, three Xtream sources, M3U, and linked cached user data.
|
||||
It exercises source-list UI, alternate last-used sources, retained settings,
|
||||
recovery consent, existing data, write failure, restart, and deletion after
|
||||
migration. Local macOS Electron verification does not substitute for installed
|
||||
migration. The refusal case holds a mocked recovery dialog pending until startup
|
||||
reaches it, then checks that Sources renders the current source in the same
|
||||
launch. Native operating-system dialogs still require separate manual verification.
|
||||
The same suite holds EPG reconciliation pending and injects failed metadata reads
|
||||
to check preparation, error feedback and recovery through Retry, including a
|
||||
second failure after importing a backup into an already loaded session. These use
|
||||
controlled IPC faults rather than adding a large, timing-dependent database to CI.
|
||||
Local macOS Electron verification does not substitute for installed
|
||||
Linux Mint MATE, Snap, or Flatpak upgrade testing.
|
||||
|
||||
## Module Structure
|
||||
|
||||
@@ -12,6 +12,7 @@ export const PlaylistActions = createActionGroup({
|
||||
events: {
|
||||
'Load Playlists': emptyProps(),
|
||||
'Load Playlists Success': props<{ playlists: PlaylistMeta[] }>(),
|
||||
'Load Playlists Failure': emptyProps(),
|
||||
'Add Playlist': props<{ playlist: Playlist }>(),
|
||||
'Add Many Playlists': props<{ playlists: Playlist[] }>(),
|
||||
'Remove Playlist': props<{ playlistId: string }>(),
|
||||
|
||||
@@ -13,18 +13,23 @@ import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
EMPTY,
|
||||
catchError,
|
||||
concatMap,
|
||||
defer,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
mergeMap,
|
||||
of,
|
||||
retry,
|
||||
switchMap,
|
||||
tap,
|
||||
withLatestFrom,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
DataService,
|
||||
EpgSourceSettingsService,
|
||||
PlaylistsService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
@@ -72,6 +77,7 @@ export class PlaylistEffects {
|
||||
private store = inject(Store);
|
||||
private translate = inject(TranslateService);
|
||||
private settingsStore = inject(SettingsStore);
|
||||
private epgSources = inject(EpgSourceSettingsService);
|
||||
private readonly playlistScopedEpgFetchKeys = new Map<string, string>();
|
||||
|
||||
updateFavorites$ = createEffect(
|
||||
@@ -257,7 +263,19 @@ export class PlaylistEffects {
|
||||
return this.actions$.pipe(
|
||||
ofType(PlaylistActions.loadPlaylists),
|
||||
switchMap(() =>
|
||||
this.playlistsService.getAllPlaylists().pipe(
|
||||
defer(() => this.playlistsService.getAllPlaylists()).pipe(
|
||||
// Recreate the storage request once for transient failures.
|
||||
// A final failure is state, not an empty source inventory.
|
||||
retry({ count: 1, delay: 300 }),
|
||||
switchMap((playlists) =>
|
||||
defer(async () => {
|
||||
// Settings can register initial cleanup after the
|
||||
// faster inventory read has already completed.
|
||||
await this.settingsStore.loadSettings();
|
||||
await this.epgSources.retryFailedReconciliation();
|
||||
return playlists;
|
||||
})
|
||||
),
|
||||
tap((playlists) => {
|
||||
this.fetchPlaylistScopedEpgForPlaylists(playlists);
|
||||
}),
|
||||
@@ -265,7 +283,8 @@ export class PlaylistEffects {
|
||||
PlaylistActions.loadPlaylistsSuccess({
|
||||
playlists,
|
||||
})
|
||||
)
|
||||
),
|
||||
catchError(() => of(PlaylistActions.loadPlaylistsFailure()))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Actions } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
EpgSourceSettingsService,
|
||||
PlaylistsService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { EMPTY, of, Subject, throwError } from 'rxjs';
|
||||
import { PlaylistActions } from './actions';
|
||||
import { PlaylistEffects } from './effects';
|
||||
|
||||
// Each subscription must create a fresh storage request, not replay the same
|
||||
// rejected promise. Exhausted retries must leave the action stream usable.
|
||||
describe('PlaylistEffects loadPlaylists$', () => {
|
||||
let actions$: Subject<unknown>;
|
||||
let getAllPlaylists: jest.Mock;
|
||||
let effects: PlaylistEffects;
|
||||
let retryFailedReconciliation: jest.Mock;
|
||||
let loadSettings: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
actions$ = new Subject();
|
||||
getAllPlaylists = jest.fn(() => of([]));
|
||||
retryFailedReconciliation = jest.fn().mockResolvedValue(undefined);
|
||||
loadSettings = jest.fn().mockResolvedValue(undefined);
|
||||
const injector = Injector.create({
|
||||
parent: { get: () => ({}) } as unknown as Injector,
|
||||
providers: [
|
||||
{ provide: Actions, useValue: new Actions(actions$) },
|
||||
{ provide: Store, useValue: { select: () => EMPTY } },
|
||||
{ provide: PlaylistsService, useValue: { getAllPlaylists } },
|
||||
{ provide: EpgService, useValue: { fetchEpg: jest.fn() } },
|
||||
{ provide: Router, useValue: {} },
|
||||
{
|
||||
provide: EpgSourceSettingsService,
|
||||
useValue: { retryFailedReconciliation },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
loadSettings,
|
||||
getSettings: () => ({ epgUrl: [] }),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
effects = runInInjectionContext(injector, () => new PlaylistEffects());
|
||||
});
|
||||
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('waits for slow settings to register initial cleanup before publishing inventory', async () => {
|
||||
let finishSettings!: () => void;
|
||||
loadSettings.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
finishSettings = resolve;
|
||||
})
|
||||
);
|
||||
const results: unknown[] = [];
|
||||
const subscription = effects.loadPlaylists$.subscribe((value) =>
|
||||
results.push(value)
|
||||
);
|
||||
actions$.next(PlaylistActions.loadPlaylists());
|
||||
await jest.runAllTimersAsync();
|
||||
expect(getAllPlaylists).toHaveBeenCalledTimes(1);
|
||||
expect(results).toEqual([]);
|
||||
expect(retryFailedReconciliation).not.toHaveBeenCalled();
|
||||
// SettingsStore completes after swallowing the first cleanup failure.
|
||||
retryFailedReconciliation.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('cleanup still unavailable'))
|
||||
);
|
||||
finishSettings();
|
||||
await jest.runAllTimersAsync();
|
||||
expect(results).toEqual([PlaylistActions.loadPlaylistsFailure()]);
|
||||
actions$.next(PlaylistActions.loadPlaylists());
|
||||
await jest.runAllTimersAsync();
|
||||
expect(results.at(-1)).toEqual(
|
||||
PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
|
||||
);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('waits for failed EPG reconciliation to recover before exposing sources', async () => {
|
||||
let finish!: () => void;
|
||||
retryFailedReconciliation.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
})
|
||||
);
|
||||
const results: unknown[] = [];
|
||||
const subscription = effects.loadPlaylists$.subscribe((value) =>
|
||||
results.push(value)
|
||||
);
|
||||
actions$.next(PlaylistActions.loadPlaylists());
|
||||
await jest.runAllTimersAsync();
|
||||
expect(results).toEqual([]);
|
||||
finish();
|
||||
await jest.runAllTimersAsync();
|
||||
expect(results).toEqual([
|
||||
PlaylistActions.loadPlaylistsSuccess({ playlists: [] }),
|
||||
]);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('keeps a failed EPG recovery actionable and retries it on the next load', async () => {
|
||||
retryFailedReconciliation.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('cleanup failed'))
|
||||
);
|
||||
const results: unknown[] = [];
|
||||
const subscription = effects.loadPlaylists$.subscribe((value) =>
|
||||
results.push(value)
|
||||
);
|
||||
actions$.next(PlaylistActions.loadPlaylists());
|
||||
await jest.runAllTimersAsync();
|
||||
expect(results).toEqual([PlaylistActions.loadPlaylistsFailure()]);
|
||||
actions$.next(PlaylistActions.loadPlaylists());
|
||||
await jest.runAllTimersAsync();
|
||||
expect(results.at(-1)).toEqual(
|
||||
PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
|
||||
);
|
||||
expect(retryFailedReconciliation).toHaveBeenCalledTimes(2);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('recovers a transient read error without restarting', async () => {
|
||||
getAllPlaylists.mockReturnValueOnce(
|
||||
throwError(() => new Error('temporary'))
|
||||
);
|
||||
const results: unknown[] = [];
|
||||
const errors: unknown[] = [];
|
||||
const subscription = effects.loadPlaylists$.subscribe({
|
||||
next: (value) => results.push(value),
|
||||
error: (error) => errors.push(error),
|
||||
});
|
||||
actions$.next(PlaylistActions.loadPlaylists());
|
||||
await jest.runAllTimersAsync();
|
||||
expect(errors).toEqual([]);
|
||||
expect(getAllPlaylists).toHaveBeenCalledTimes(2);
|
||||
expect(results).toEqual([
|
||||
PlaylistActions.loadPlaylistsSuccess({ playlists: [] }),
|
||||
]);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('reports persistent failure without treating it as an empty library and accepts Retry', async () => {
|
||||
getAllPlaylists.mockReturnValue(
|
||||
throwError(() => new Error('private storage error'))
|
||||
);
|
||||
const results: unknown[] = [];
|
||||
const errors: unknown[] = [];
|
||||
const subscription = effects.loadPlaylists$.subscribe({
|
||||
next: (value) => results.push(value),
|
||||
error: (error) => errors.push(error),
|
||||
});
|
||||
actions$.next(PlaylistActions.loadPlaylists());
|
||||
await jest.runAllTimersAsync();
|
||||
expect(errors).toEqual([]);
|
||||
expect(getAllPlaylists).toHaveBeenCalledTimes(2);
|
||||
expect(results).toEqual([
|
||||
{ type: '[Playlists] Load Playlists Failure' },
|
||||
]);
|
||||
getAllPlaylists.mockReturnValue(of([]));
|
||||
actions$.next(PlaylistActions.loadPlaylists());
|
||||
await jest.runAllTimersAsync();
|
||||
expect(results.at(-1)).toEqual(
|
||||
PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
|
||||
);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export const playlistsAdapter: EntityAdapter<PlaylistMeta> =
|
||||
export interface PlaylistMetaState extends EntityState<PlaylistMeta> {
|
||||
selectedId: string;
|
||||
allPlaylistsLoaded: boolean;
|
||||
loadFailed: boolean;
|
||||
selectedFilters: string[];
|
||||
}
|
||||
|
||||
@@ -16,6 +17,7 @@ export const initialPlaylistMetaState: PlaylistMetaState =
|
||||
playlistsAdapter.getInitialState({
|
||||
selectedId: '',
|
||||
allPlaylistsLoaded: false,
|
||||
loadFailed: false,
|
||||
selectedFilters: ['m3u', 'xtream', 'stalker'],
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,46 @@ import { Channel, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
const reducer = createReducer(initialState, ...playlistReducers);
|
||||
|
||||
describe('playlistReducers', () => {
|
||||
it('keeps a failed initial inventory unready until a retry succeeds', () => {
|
||||
const failed = reducer(
|
||||
initialState,
|
||||
PlaylistActions.loadPlaylistsFailure()
|
||||
);
|
||||
expect(failed.playlists.loadFailed).toBe(true);
|
||||
expect(failed.playlists.allPlaylistsLoaded).toBe(false);
|
||||
const retrying = reducer(failed, PlaylistActions.loadPlaylists());
|
||||
expect(retrying.playlists.loadFailed).toBe(false);
|
||||
expect(retrying.playlists.allPlaylistsLoaded).toBe(false);
|
||||
const recovered = reducer(
|
||||
retrying,
|
||||
PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
|
||||
);
|
||||
expect(recovered.playlists.loadFailed).toBe(false);
|
||||
expect(recovered.playlists.allPlaylistsLoaded).toBe(true);
|
||||
});
|
||||
|
||||
it('clears readiness when a backup replaces an already loaded inventory', () => {
|
||||
const loaded = reducer(
|
||||
initialState,
|
||||
PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
|
||||
);
|
||||
const emptied = reducer(loaded, PlaylistActions.removeAllPlaylists());
|
||||
const loading = reducer(emptied, PlaylistActions.loadPlaylists());
|
||||
expect(loading.playlists.allPlaylistsLoaded).toBe(false);
|
||||
const failed = reducer(loading, PlaylistActions.loadPlaylistsFailure());
|
||||
expect(failed.playlists.allPlaylistsLoaded).toBe(false);
|
||||
expect(failed.playlists.loadFailed).toBe(true);
|
||||
const retrying = reducer(failed, PlaylistActions.loadPlaylists());
|
||||
expect(retrying.playlists.allPlaylistsLoaded).toBe(false);
|
||||
expect(retrying.playlists.loadFailed).toBe(false);
|
||||
const recovered = reducer(
|
||||
retrying,
|
||||
PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
|
||||
);
|
||||
expect(recovered.playlists.allPlaylistsLoaded).toBe(true);
|
||||
expect(recovered.playlists.loadFailed).toBe(false);
|
||||
});
|
||||
|
||||
it('persists updateDate and hiddenGroupTitles when playlist meta is updated', () => {
|
||||
const existingPlaylist: PlaylistMeta = {
|
||||
_id: 'playlist-1',
|
||||
|
||||
@@ -6,12 +6,25 @@ import { playlistsAdapter } from '../playlists.state';
|
||||
import { PlaylistState } from '../state';
|
||||
|
||||
export const playlistReducers = [
|
||||
on(PlaylistActions.loadPlaylists, (state): PlaylistState => ({
|
||||
...state,
|
||||
playlists: {
|
||||
...state.playlists,
|
||||
allPlaylistsLoaded: false,
|
||||
loadFailed: false,
|
||||
},
|
||||
})),
|
||||
on(PlaylistActions.loadPlaylistsFailure, (state): PlaylistState => ({
|
||||
...state,
|
||||
playlists: { ...state.playlists, loadFailed: true },
|
||||
})),
|
||||
on(PlaylistActions.loadPlaylistsSuccess, (state, action): PlaylistState => {
|
||||
return {
|
||||
...state,
|
||||
playlists: playlistsAdapter.addMany(action.playlists, {
|
||||
...state.playlists,
|
||||
allPlaylistsLoaded: true,
|
||||
loadFailed: false,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -53,6 +53,11 @@ export const selectPlaylistsMetaState = createSelector(
|
||||
fromPlaylistState.selectPlaylists
|
||||
);
|
||||
|
||||
export const selectPlaylistsLoadFailed = createSelector(
|
||||
selectPlaylistsMetaState,
|
||||
(state) => state.loadFailed === true
|
||||
);
|
||||
|
||||
export const selectAllPlaylistsMeta = createSelector(
|
||||
selectPlaylistsMetaState,
|
||||
fromPlaylistMetaState.getAllPlaylistsMeta
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injector } from '@angular/core';
|
||||
import { firstValueFrom, of, Subject } from 'rxjs';
|
||||
import { firstValueFrom, of, Subject, throwError } from 'rxjs';
|
||||
import { EpgSourceSettingsService } from './epg-source-settings.service';
|
||||
import { PlaylistsService } from './playlists.service';
|
||||
|
||||
@@ -109,4 +109,70 @@ describe('EPG source settings synchronization', () => {
|
||||
]);
|
||||
expect(service.retainCurrentSources(['a', 'b'], 0)).toEqual(['b']);
|
||||
});
|
||||
it('retries failed inventory ownership before pruning, using the committed URLs', async () => {
|
||||
const getAllPlaylists = jest
|
||||
.fn()
|
||||
.mockReturnValue(throwError(() => new Error('disk')));
|
||||
const reconcileEpgSources = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true });
|
||||
window.electron = {
|
||||
reconcileEpgSources,
|
||||
} as unknown as typeof window.electron;
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
EpgSourceSettingsService,
|
||||
{ provide: PlaylistsService, useValue: { getAllPlaylists } },
|
||||
],
|
||||
});
|
||||
const service = injector.get(EpgSourceSettingsService);
|
||||
// No known committed settings means no permission to prune defaults.
|
||||
await service.retryFailedReconciliation();
|
||||
expect(getAllPlaylists).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
service.synchronize([' current ', 'current'])
|
||||
).rejects.toThrow();
|
||||
expect(reconcileEpgSources).not.toHaveBeenCalled();
|
||||
getAllPlaylists.mockReturnValue(of([]));
|
||||
await service.retryFailedReconciliation();
|
||||
expect(reconcileEpgSources).toHaveBeenCalledWith(['current']);
|
||||
await service.retryFailedReconciliation();
|
||||
expect(reconcileEpgSources).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('waits for a newer committed synchronization without replaying an older failed set', async () => {
|
||||
let finish!: (result: { success: boolean }) => void;
|
||||
const reconcileEpgSources = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ success: false })
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
})
|
||||
);
|
||||
window.electron = {
|
||||
reconcileEpgSources,
|
||||
} as unknown as typeof window.electron;
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
EpgSourceSettingsService,
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: { getAllPlaylists: () => of([]) },
|
||||
},
|
||||
],
|
||||
});
|
||||
const service = injector.get(EpgSourceSettingsService);
|
||||
await expect(service.synchronize(['old'])).rejects.toThrow();
|
||||
const recovery = service.retryFailedReconciliation();
|
||||
const latest = service.synchronize(['new']);
|
||||
await Promise.resolve();
|
||||
finish({ success: true });
|
||||
await Promise.all([latest, recovery]);
|
||||
expect(reconcileEpgSources.mock.calls).toEqual([[['old']], [['new']]]);
|
||||
expect(service.retainCurrentSources(['old', 'new'], 0)).toEqual([
|
||||
'new',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ export class EpgSourceSettingsService {
|
||||
private readonly injector = inject(Injector);
|
||||
private activeUrls = new Set<string>();
|
||||
private reconciliation: Promise<void> | undefined;
|
||||
private failedReconciliation: { urls: string[] } | undefined;
|
||||
readonly revision = signal(0);
|
||||
readonly changed$ = new Subject<void>();
|
||||
|
||||
@@ -66,6 +67,18 @@ export class EpgSourceSettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** A recovered inventory must finish any previously authorized cleanup. */
|
||||
async retryFailedReconciliation(): Promise<void> {
|
||||
// A save can start between the wait resolving and this continuation.
|
||||
// Recheck ownership before scheduling a retry of the failed URL set.
|
||||
do {
|
||||
await this.waitForReconciliation();
|
||||
} while (this.reconciliation);
|
||||
if (this.failedReconciliation) {
|
||||
await this.synchronize(this.failedReconciliation.urls);
|
||||
}
|
||||
}
|
||||
|
||||
async synchronize(urls: string[] | string | undefined): Promise<void> {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
@@ -108,7 +121,9 @@ export class EpgSourceSettingsService {
|
||||
await window.electron.reconcileEpgSources(normalized);
|
||||
if (!result.success)
|
||||
throw new Error('EPG source reconciliation failed');
|
||||
this.failedReconciliation = undefined;
|
||||
} catch {
|
||||
this.failedReconciliation = { urls: normalized };
|
||||
throw new EpgSourceReconciliationError();
|
||||
} finally {
|
||||
this.revision.update((revision) => revision + 1);
|
||||
|
||||
@@ -40,8 +40,19 @@ export {
|
||||
XTREAM_MOCK_ORIGIN,
|
||||
} from './capture-fixtures';
|
||||
|
||||
/** Synthetic categories that only the marketing fixture generator produces. */
|
||||
const MOCK_FIXTURE_CATEGORIES = ['Action & Mystery', 'Urban Drama'];
|
||||
/**
|
||||
* Synthetic categories that only the marketing fixture generator produces,
|
||||
* per catalog. They must be checked against their OWN endpoint: a series
|
||||
* category can never appear in `get_vod_categories`, and asserting it there
|
||||
* made the reuse path below reject every already-running mock.
|
||||
*/
|
||||
const MOCK_FIXTURE_CATEGORIES: ReadonlyArray<{
|
||||
action: 'get_vod_categories' | 'get_series_categories';
|
||||
name: string;
|
||||
}> = [
|
||||
{ action: 'get_vod_categories', name: 'Action & Mystery' },
|
||||
{ action: 'get_series_categories', name: 'Urban Drama' },
|
||||
];
|
||||
/**
|
||||
* Live categories of the Stalker mock's marketing-demo scenario, from
|
||||
* `MARKETING_LIVE_CATEGORIES` in `@iptvnator/shared/marketing-fixtures`.
|
||||
@@ -203,27 +214,27 @@ async function assertStalkerMockServerIdentity(): Promise<void> {
|
||||
* the marketing catalog with the exact synthetic titles the shots rely on.
|
||||
*/
|
||||
async function assertMockServerIdentity(): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${XTREAM_MOCK_ORIGIN}/player_api.php?username=marketing&password=marketing&action=get_vod_categories`
|
||||
).catch(() => null);
|
||||
|
||||
if (!response?.ok) {
|
||||
throw new Error(
|
||||
`Something is listening on ${XTREAM_MOCK_ORIGIN} but does not answer the Xtream marketing API — stop it and let this script start the mock server itself.`
|
||||
);
|
||||
}
|
||||
|
||||
const categories = (await response.json().catch(() => null)) as
|
||||
| { category_name?: string }[]
|
||||
| null;
|
||||
const names = new Set(
|
||||
(categories ?? []).map((entry) => entry.category_name)
|
||||
);
|
||||
|
||||
for (const expected of MOCK_FIXTURE_CATEGORIES) {
|
||||
if (!names.has(expected)) {
|
||||
const response = await fetch(
|
||||
`${XTREAM_MOCK_ORIGIN}/player_api.php?username=marketing&password=marketing&action=${expected.action}`
|
||||
).catch(() => null);
|
||||
|
||||
if (!response?.ok) {
|
||||
throw new Error(
|
||||
`The server on ${XTREAM_MOCK_ORIGIN} is not the IPTVnator marketing mock (missing category "${expected}"). Refusing to capture screenshots from unknown data.`
|
||||
`Something is listening on ${XTREAM_MOCK_ORIGIN} but does not answer the Xtream marketing API — stop it and let this script start the mock server itself.`
|
||||
);
|
||||
}
|
||||
|
||||
const categories = (await response.json().catch(() => null)) as
|
||||
| { category_name?: string }[]
|
||||
| null;
|
||||
const names = new Set(
|
||||
(categories ?? []).map((entry) => entry.category_name)
|
||||
);
|
||||
|
||||
if (!names.has(expected.name)) {
|
||||
throw new Error(
|
||||
`The server on ${XTREAM_MOCK_ORIGIN} is not the IPTVnator marketing mock (missing ${expected.action} entry "${expected.name}"). Refusing to capture screenshots from unknown data.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ export const CAPTURE_DOWNLOAD_FOLDER_NAME = 'IPTVnator downloads';
|
||||
export const M3U_FIXTURE_PLAYLIST_URL = `${XTREAM_MOCK_ORIGIN}/demo/channels.m3u8`;
|
||||
export const M3U_FIXTURE_PLAYLIST_TITLE = 'Fictional TV playlist';
|
||||
export const EPG_FIXTURE_URL = `${XTREAM_MOCK_ORIGIN}/demo/guide.xml.gz`;
|
||||
/** Served by the Xtream mock (`/demo/guide.xml`); the EPG-mapping guide shots import it for real. */
|
||||
export const DEMO_EPG_URL = `${XTREAM_MOCK_ORIGIN}/demo/guide.xml`;
|
||||
|
||||
/** A fictional "your subscription is ready" message for the Auto-detect shot. */
|
||||
export const AUTO_DETECT_FIXTURE_MESSAGE = [
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Setup actions for the EPG-mapping guide shots: importing the mock's XMLTV
|
||||
* guide, opening a channel's context menu and staging the mapping dialog.
|
||||
* They sit between the settings pages (`capture-navigation-setup-actions.ts`)
|
||||
* and the channel lists (`capture-navigation-portal-actions.ts`), so they
|
||||
* borrow one entry point from each rather than duplicating the navigation.
|
||||
*/
|
||||
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { DEMO_EPG_URL } from './capture-fixtures';
|
||||
import { type CaptureAction, settleUi } from './capture-navigation-helpers';
|
||||
import { openM3uGroups } from './capture-navigation-portal-actions';
|
||||
import { openEpgSettingsSection } from './capture-navigation-setup-actions';
|
||||
|
||||
async function openSettingsEpgOffset(page: Page): Promise<void> {
|
||||
await openEpgSettingsSection(page);
|
||||
const offset = page.locator('[data-test-id="epg-offset-minutes"]');
|
||||
|
||||
await offset.waitFor({ state: 'visible', timeout: 10_000 });
|
||||
// Staged in the form only; discarded before the next action.
|
||||
await offset.fill('60');
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the mock's XMLTV guide as an EPG source and saves the settings, so
|
||||
* the mapping dialog has channels to search. Idempotent: a saved row with the
|
||||
* demo URL means the guide is already in the isolated database.
|
||||
*/
|
||||
async function loadDemoEpg(page: Page): Promise<void> {
|
||||
await openEpgSettingsSection(page);
|
||||
const section = page.locator('#epg');
|
||||
const existing = await section
|
||||
.locator('.epg-source-row input')
|
||||
.evaluateAll((inputs) =>
|
||||
inputs.map((input) => (input as HTMLInputElement).value)
|
||||
);
|
||||
|
||||
if (existing.includes(DEMO_EPG_URL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await section
|
||||
.getByRole('button', { name: /add epg source/i })
|
||||
.click({ timeout: 10_000 });
|
||||
const field = section.locator('.epg-source-row input').last();
|
||||
await field.waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await field.fill(DEMO_EPG_URL, { timeout: 10_000 });
|
||||
// The row's first icon button fetches the source right away.
|
||||
await section
|
||||
.locator('.epg-source-row')
|
||||
.last()
|
||||
.locator('button')
|
||||
.first()
|
||||
.click();
|
||||
// A loopback source trips the app's private-network confirmation; the
|
||||
// mock is the one private address this capture trusts.
|
||||
const allow = page.getByRole('button', { name: /allow source/i });
|
||||
|
||||
if (
|
||||
await allow
|
||||
.waitFor({ state: 'visible', timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
) {
|
||||
await allow.click();
|
||||
}
|
||||
|
||||
await page
|
||||
.locator(
|
||||
'.epg-progress-panel .import-item.status-complete, .epg-progress-panel .stat-badge'
|
||||
)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
|
||||
const save = page.locator('[data-test-id="save-settings"]').first();
|
||||
|
||||
if (await save.isEnabled().catch(() => false)) {
|
||||
await save.click();
|
||||
}
|
||||
|
||||
await settleUi(page);
|
||||
}
|
||||
|
||||
/** Right-clicks the first channel of the M3U groups view and waits for its menu. */
|
||||
async function openM3uChannelContextMenu(page: Page): Promise<void> {
|
||||
await openM3uGroups(page);
|
||||
const channel = page.locator('app-channel-list-item').first();
|
||||
|
||||
await channel.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await channel.click({ button: 'right' });
|
||||
await page
|
||||
.getByRole('menuitem', { name: /map epg channel/i })
|
||||
.waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
async function openEpgMappingDialog(page: Page): Promise<void> {
|
||||
await loadDemoEpg(page);
|
||||
await openM3uChannelContextMenu(page);
|
||||
await page.getByRole('menuitem', { name: /map epg channel/i }).click();
|
||||
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
|
||||
await dialog.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await dialog.locator('input').first().fill('Aurora');
|
||||
|
||||
const result = dialog.locator('.epg-mapping-result').first();
|
||||
|
||||
await result.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await result.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
export const EPG_ACTIONS: Readonly<Record<string, CaptureAction>> = {
|
||||
'open-settings-epg-offset': openSettingsEpgOffset,
|
||||
'load-demo-epg': loadDemoEpg,
|
||||
'open-m3u-channel-menu': openM3uChannelContextMenu,
|
||||
'open-epg-mapping-dialog': openEpgMappingDialog,
|
||||
};
|
||||
@@ -58,7 +58,7 @@ export async function openXtreamSeries(
|
||||
await page.waitForTimeout(700);
|
||||
}
|
||||
|
||||
async function openM3uGroups(page: Page): Promise<void> {
|
||||
export async function openM3uGroups(page: Page): Promise<void> {
|
||||
const playlistId = requirePlaylistId('playlists');
|
||||
|
||||
await goHome(page);
|
||||
|
||||
@@ -129,7 +129,8 @@ async function openAddPlaylistM3uUrl(page: Page): Promise<void> {
|
||||
/* Settings sections */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function openSettingsEpg(page: Page): Promise<void> {
|
||||
/** Navigates to Settings › EPG and waits for the section, without staging anything. */
|
||||
export async function openEpgSettingsSection(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
const sectionLink = page
|
||||
.locator('[data-test-id="settings-section-epg"]')
|
||||
@@ -138,20 +139,28 @@ async function openSettingsEpg(page: Page): Promise<void> {
|
||||
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await sectionLink.click({ timeout: 10_000 });
|
||||
await page.waitForURL(/\/workspace\/settings\/epg/, { timeout: 15_000 });
|
||||
await page.locator('#epg').waitFor({ state: 'visible', timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function openSettingsEpg(page: Page): Promise<void> {
|
||||
await openEpgSettingsSection(page);
|
||||
const section = page.locator('#epg');
|
||||
await section.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
|
||||
// Show a filled source row instead of the empty state. The value
|
||||
// is staged in the form only; nothing is saved or fetched. The
|
||||
// dirty form is discarded by `discardUnsavedSettings` before the
|
||||
// next action or the app teardown — the settings close guard
|
||||
// would otherwise hold `app.close()` open forever.
|
||||
await section
|
||||
.getByRole('button', { name: /add epg source/i })
|
||||
.click({ timeout: 10_000 });
|
||||
const field = section.locator('input[type="url"]').last();
|
||||
await field.waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await field.fill(EPG_FIXTURE_URL, { timeout: 10_000 });
|
||||
// would otherwise hold `app.close()` open forever. A row a previous
|
||||
// step already saved (`load-demo-epg`) fills the frame on its own.
|
||||
if ((await section.locator('.epg-source-row').count()) === 0) {
|
||||
await section
|
||||
.getByRole('button', { name: /add epg source/i })
|
||||
.click({ timeout: 10_000 });
|
||||
const field = section.locator('input[type="url"]').last();
|
||||
await field.waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await field.fill(EPG_FIXTURE_URL, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* (`capture-navigation-setup-actions.ts`: dialogs, settings, remote
|
||||
* control; `capture-navigation-portal-actions.ts`: portal browsing and
|
||||
* alternative sources; `capture-navigation-download-actions.ts`: the
|
||||
* download manager) over the shared page helpers in
|
||||
* download manager; `capture-navigation-epg-actions.ts`: the EPG guide
|
||||
* import and channel mapping) over the shared page helpers in
|
||||
* `capture-navigation-helpers.ts`. This file only dispatches a step name
|
||||
* and re-exports the API the seeding driver and the capture script use.
|
||||
*/
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
settleUi,
|
||||
} from './capture-navigation-helpers';
|
||||
import { DOWNLOAD_ACTIONS } from './capture-navigation-download-actions';
|
||||
import { EPG_ACTIONS } from './capture-navigation-epg-actions';
|
||||
import { PORTAL_ACTIONS } from './capture-navigation-portal-actions';
|
||||
import { SETUP_ACTIONS } from './capture-navigation-setup-actions';
|
||||
|
||||
@@ -38,6 +40,7 @@ const ACTIONS: Readonly<Record<string, CaptureAction>> = {
|
||||
...SETUP_ACTIONS,
|
||||
...PORTAL_ACTIONS,
|
||||
...DOWNLOAD_ACTIONS,
|
||||
...EPG_ACTIONS,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -59,6 +59,10 @@ export const KNOWN_ACTIONS = [
|
||||
'open-xtream-live-channel',
|
||||
'open-settings-remote-control',
|
||||
'enable-remote-control',
|
||||
'open-settings-epg-offset',
|
||||
'load-demo-epg',
|
||||
'open-m3u-channel-menu',
|
||||
'open-epg-mapping-dialog',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -171,6 +171,30 @@
|
||||
"height": 1100
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "guide-epg-map-menu",
|
||||
"title": "Channel context menu with Map EPG channel",
|
||||
"group": "guides",
|
||||
"setup": [
|
||||
"open-m3u-channel-menu"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "guide-epg-map-dialog",
|
||||
"title": "Map EPG channel dialog with a search result",
|
||||
"group": "guides",
|
||||
"setup": [
|
||||
"open-epg-mapping-dialog"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "guide-epg-offset",
|
||||
"title": "Settings: EPG time offset",
|
||||
"group": "guides",
|
||||
"setup": [
|
||||
"open-settings-epg-offset"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -57,6 +57,14 @@ const GUIDES = [
|
||||
'blog/guides/screenshots/guide-remote-phone-dark.png',
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'epg-wrong-program-fix',
|
||||
screenshots: [
|
||||
'blog/guides/screenshots/guide-epg-map-menu-dark.png',
|
||||
'blog/guides/screenshots/guide-epg-map-dialog-dark.png',
|
||||
'blog/guides/screenshots/guide-epg-offset-dark.png',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
|
||||
|
||||
Reference in new issue
Block a user