mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(migration): show startup preparation and recover source read failures (#1568)
* fix(migration): show startup preparation and recover source read failures * fix(migration): keep inventory reload failures recoverable * test(migration): type the deferred recovery hook * fix(migration): reconcile failed EPG cleanup before recovery * fix(migration): await settings before inventory readiness * style(migration): add a subtle theme-aware startup watermark
This commit is contained in:
1 parent
97b0264dee
commit
9f950a1530
37 files changed
+1024
-14
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;
|
||||
}
|
||||
|
||||
@@ -1718,5 +1718,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "لا توجد بيانات للحساب",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "لا تنشر هذه البوابة معلومات الحساب لهذا الجهاز."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "جارٍ تجهيز المكتبة",
|
||||
"PREPARING_DETAIL": "جارٍ تحميل المصادر والتحقق من دليل البرامج. قد تستغرق المكتبات الكبيرة وقتًا أطول.",
|
||||
"FAILED": "تعذر تحميل المصادر",
|
||||
"FAILED_DETAIL": "أعد محاولة قراءة المصادر المحفوظة. لم تُحذف بياناتك."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "ما كايناش بيانات الحساب",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "هاد البوابة ما كتعطيش معلومات الحساب لهاد الجهاز."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "كنوجدو المكتبة ديالك",
|
||||
"PREPARING_DETAIL": "كنحمّلو المصادر وكنراجعو دليل البرامج. المكتبات الكبيرة تقدر تاخد وقت أكثر.",
|
||||
"FAILED": "ما قدرناش نحمّلو المصادر",
|
||||
"FAILED_DETAIL": "عاود جرّب تقرا المصادر المحفوظة. البيانات ديالك ما تمسحوش."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Няма даных пра акаўнт",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Гэты партал не дае інфармацыі пра акаўнт для гэтай прылады."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Падрыхтоўка бібліятэкі",
|
||||
"PREPARING_DETAIL": "Загружаем крыніцы і правяраем тэлепраграму. Вялікая бібліятэка можа патрабаваць больш часу.",
|
||||
"FAILED": "Не ўдалося загрузіць крыніцы",
|
||||
"FAILED_DETAIL": "Паўтарыце чытанне захаваных крыніц. Вашы даныя не выдалены."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,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."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Δεν υπάρχουν στοιχεία λογαριασμού",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Αυτή η πύλη δεν δημοσιεύει πληροφορίες λογαριασμού για αυτήν τη συσκευή."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Προετοιμασία βιβλιοθήκης",
|
||||
"PREPARING_DETAIL": "Φόρτωση πηγών και έλεγχος του οδηγού προγράμματος. Οι μεγάλες βιβλιοθήκες μπορεί να χρειαστούν περισσότερο χρόνο.",
|
||||
"FAILED": "Δεν ήταν δυνατή η φόρτωση των πηγών",
|
||||
"FAILED_DETAIL": "Δοκιμάστε ξανά την ανάγνωση των αποθηκευμένων πηγών. Τα δεδομένα σας δεν διαγράφηκαν."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,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."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,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."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,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."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,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."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,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."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "アカウント情報なし",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "このポータルは、このデバイスのアカウント情報を公開していません。"
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "ライブラリを準備しています",
|
||||
"PREPARING_DETAIL": "ソースを読み込み、番組表を確認しています。大きなライブラリでは時間がかかる場合があります。",
|
||||
"FAILED": "ソースを読み込めませんでした",
|
||||
"FAILED_DETAIL": "保存したソースの読み込みを再試行してください。データは削除されていません。"
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "계정 정보 없음",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "이 포털은 이 기기에 대한 계정 정보를 제공하지 않습니다."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "라이브러리 준비 중",
|
||||
"PREPARING_DETAIL": "소스를 불러오고 편성표를 확인하고 있습니다. 라이브러리가 크면 시간이 더 걸릴 수 있습니다.",
|
||||
"FAILED": "소스를 불러오지 못했습니다",
|
||||
"FAILED_DETAIL": "저장된 소스를 다시 읽어 보세요. 데이터는 삭제되지 않았습니다."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,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."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,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."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,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."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "Нет данных об аккаунте",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Этот портал не предоставляет информацию об аккаунте для этого устройства."
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "Подготовка библиотеки",
|
||||
"PREPARING_DETAIL": "Загружаем источники и проверяем телепрограмму. Большая библиотека может потребовать больше времени.",
|
||||
"FAILED": "Не удалось загрузить источники",
|
||||
"FAILED_DETAIL": "Повторите чтение сохранённых источников. Ваши данные не удалены."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,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."
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "无账户数据",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "该门户不为此设备提供账户信息。"
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "正在准备媒体库",
|
||||
"PREPARING_DETAIL": "正在加载来源并检查节目指南。较大的媒体库可能需要更长时间。",
|
||||
"FAILED": "无法加载来源",
|
||||
"FAILED_DETAIL": "请重试读取已保存的来源。您的数据未被删除。"
|
||||
}
|
||||
}
|
||||
@@ -1718,5 +1718,11 @@
|
||||
"NO_ACCOUNT_DATA_TITLE": "沒有帳戶資料",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "此入口網站不提供此裝置的帳戶資訊。"
|
||||
}
|
||||
},
|
||||
"STARTUP": {
|
||||
"PREPARING": "正在準備媒體庫",
|
||||
"PREPARING_DETAIL": "正在載入來源並檢查節目指南。較大的媒體庫可能需要更長時間。",
|
||||
"FAILED": "無法載入來源",
|
||||
"FAILED_DETAIL": "請重試讀取已儲存的來源。您的資料未被刪除。"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user