mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
fix(playlists): report failed playlists in the startup auto-refresh toast (#1235)
The startup auto-refresh always opened the `HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS` snackbar, even when the backend had dropped playlists it could not refresh. Isolating per-playlist failures (#1233) means the result set is lossy by design, so an unreachable source that now fails within `PLAYLIST_FETCH_TIMEOUT_MS` produces a false success toast. `autoUpdatePlaylists()` now returns `AutoUpdatePlaylistsResult` — the refreshed playlists plus one outcome per requested playlist (`updated` / `failed` / `skipped`), in request order — on top of the existing bounded-concurrency refresh. The renderer derives the message from those outcomes: - all updated -> `AUTO_REFRESH_UPDATE_SUCCESS` (unchanged) - some failed -> `AUTO_REFRESH_UPDATE_PARTIAL` (error styling, dismissable) - some failed and some skipped -> `AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED` - none updated -> `AUTO_REFRESH_UPDATE_FAILED` (error styling, dismissable) - only sourceless playlists left over -> `AUTO_REFRESH_UPDATE_SKIPPED` Playlists with neither a URL nor a file path are reported as skipped rather than failed, since there is no source to refresh them from. The mixed failed+skipped message exists because the plain partial text names only updated/total/failed, which would leave the skipped playlists as an unexplained remainder. Titles of unresolved playlists are logged for diagnosability. Tests: five new `electron.service` cases (one per message branch), outcome assertions across the existing `playlist-auto-update` and `playlist.events` specs, and an Electron E2E that restarts the app against a killed playlist server — verified to fail against the old unconditional toast. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc81f4f941
commit
f852bc7459
30 files changed
+677
-39
No files matched your search
@@ -0,0 +1,113 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import {
|
||||
closeElectronApp,
|
||||
createMutableTextServer,
|
||||
expect,
|
||||
importM3uPlaylistFromUrl,
|
||||
launchElectronApp,
|
||||
openSourceEditor,
|
||||
openSources,
|
||||
restartElectronApp,
|
||||
saveSourceDialog,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
updateSourceDialog,
|
||||
waitForM3uCatalog,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
const autoRefreshSourceName = 'auto-refresh-source.m3u';
|
||||
|
||||
function buildAutoRefreshM3u(channelName: string): string {
|
||||
return `#EXTM3U
|
||||
#EXTINF:-1 group-title="News",${channelName}
|
||||
https://streams.example.test/${channelName.toLowerCase().replace(/\s+/g, '-')}.m3u8
|
||||
`;
|
||||
}
|
||||
|
||||
function snackBarLabel(page: Page, text: string): Locator {
|
||||
return page
|
||||
.locator('.mat-mdc-snack-bar-label')
|
||||
.filter({ hasText: text })
|
||||
.last();
|
||||
}
|
||||
|
||||
async function openAutoRefreshPlaylistCatalog(page: Page): Promise<void> {
|
||||
await openSources(page);
|
||||
await sourceRowByTitle(page, autoRefreshSourceName).first().click();
|
||||
await waitForM3uCatalog(page);
|
||||
}
|
||||
|
||||
test.describe('Electron startup playlist auto-refresh', () => {
|
||||
test('reports failed playlists instead of an unconditional success toast', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const urlServer = await createMutableTextServer(
|
||||
buildAutoRefreshM3u('Original Auto Channel'),
|
||||
{
|
||||
contentType: 'application/vnd.apple.mpegurl',
|
||||
resourcePath: `/${autoRefreshSourceName}`,
|
||||
}
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromUrl(
|
||||
app.mainWindow,
|
||||
urlServer.resourceUrl
|
||||
);
|
||||
await openSources(app.mainWindow);
|
||||
const dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
autoRefreshSourceName
|
||||
);
|
||||
await updateSourceDialog(dialog, { autoRefresh: true });
|
||||
await saveSourceDialog(app.mainWindow, dialog);
|
||||
|
||||
// Reachable source: the startup run really refreshes the playlist.
|
||||
// Asserted through the persisted catalog rather than the 2s success
|
||||
// toast, which is too short-lived to poll for reliably.
|
||||
urlServer.setBody(buildAutoRefreshM3u('Refreshed Auto Channel'));
|
||||
const successfulRestart = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = successfulRestart.electronApp;
|
||||
app.mainWindow = successfulRestart.mainWindow;
|
||||
|
||||
await openAutoRefreshPlaylistCatalog(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('channel-item').filter({
|
||||
hasText: 'Refreshed Auto Channel',
|
||||
})
|
||||
).toHaveCount(1);
|
||||
|
||||
// Unreachable source: the backend drops the playlist from its
|
||||
// result, so the startup toast must say so instead of claiming
|
||||
// everything was updated.
|
||||
await urlServer.close();
|
||||
|
||||
const failedRestart = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = failedRestart.electronApp;
|
||||
app.mainWindow = failedRestart.mainWindow;
|
||||
|
||||
await expect(
|
||||
snackBarLabel(app.mainWindow, 'Playlist auto-refresh failed')
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
await expect(
|
||||
app.mainWindow.locator('.mat-mdc-snack-bar-label').filter({
|
||||
hasText: 'The playlists were successfully updated',
|
||||
})
|
||||
).toHaveCount(0);
|
||||
|
||||
// A failed refresh must not wipe the previously imported channels.
|
||||
await openAutoRefreshPlaylistCatalog(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('channel-item').filter({
|
||||
hasText: 'Refreshed Auto Channel',
|
||||
})
|
||||
).toHaveCount(1);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
// The test closes the server mid-run to make the source
|
||||
// unreachable; a second close rejects with ERR_SERVER_NOT_RUNNING.
|
||||
await urlServer.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -97,10 +97,15 @@ describe('autoUpdatePlaylists', () => {
|
||||
|
||||
unresponsive.resolve(createPlaylist({ title: 'Slow but refreshed' }));
|
||||
|
||||
await expect(updatePromise).resolves.toEqual([
|
||||
const result = await updatePromise;
|
||||
expect(result.playlists).toEqual([
|
||||
expect.objectContaining({ _id: 'unresponsive' }),
|
||||
expect.objectContaining({ _id: 'reachable' }),
|
||||
]);
|
||||
expect(result.outcomes.map((outcome) => outcome.status)).toEqual([
|
||||
'updated',
|
||||
'updated',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the playlists that succeeded when another source fails', async () => {
|
||||
@@ -115,7 +120,15 @@ describe('autoUpdatePlaylists', () => {
|
||||
createUrlPlaylist('reachable', 'https://reachable.test/list.m3u'),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([expect.objectContaining({ _id: 'reachable' })]);
|
||||
expect(result.playlists).toEqual([
|
||||
expect.objectContaining({ _id: 'reachable' }),
|
||||
]);
|
||||
// The dropped playlist must still be reported, otherwise the renderer
|
||||
// cannot tell a partial run from a complete one.
|
||||
expect(result.outcomes).toEqual([
|
||||
{ playlistId: 'broken', status: 'failed', title: 'broken' },
|
||||
{ playlistId: 'reachable', status: 'updated', title: 'reachable' },
|
||||
]);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to update playlist "broken":',
|
||||
expect.objectContaining({
|
||||
@@ -154,9 +167,9 @@ describe('autoUpdatePlaylists', () => {
|
||||
pendingFetch.resolve(createPlaylist());
|
||||
}
|
||||
|
||||
await expect(updatePromise).resolves.toHaveLength(
|
||||
pendingFetches.length
|
||||
);
|
||||
const result = await updatePromise;
|
||||
expect(result.playlists).toHaveLength(pendingFetches.length);
|
||||
expect(result.outcomes).toHaveLength(pendingFetches.length);
|
||||
});
|
||||
|
||||
it('preserves user-owned fields and refreshes file-based playlists', async () => {
|
||||
@@ -186,7 +199,7 @@ describe('autoUpdatePlaylists', () => {
|
||||
'/playlists/local.m3u',
|
||||
'File playlist'
|
||||
);
|
||||
expect(result).toEqual([
|
||||
expect(result.playlists).toEqual([
|
||||
expect.objectContaining({
|
||||
_id: 'file-playlist',
|
||||
autoRefresh: true,
|
||||
@@ -195,6 +208,13 @@ describe('autoUpdatePlaylists', () => {
|
||||
userAgent: 'PlaylistAgent/1.0',
|
||||
}),
|
||||
]);
|
||||
expect(result.outcomes).toEqual([
|
||||
{
|
||||
playlistId: 'file-playlist',
|
||||
status: 'updated',
|
||||
title: 'File playlist',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips playlists without a usable source and forwards trust options', async () => {
|
||||
@@ -216,7 +236,13 @@ describe('autoUpdatePlaylists', () => {
|
||||
{ trustedInsecureTlsHosts: ['self-signed.test'] }
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result.playlists).toHaveLength(1);
|
||||
// A playlist with no source was never refreshable, so it is reported as
|
||||
// skipped rather than failed.
|
||||
expect(result.outcomes).toEqual([
|
||||
{ playlistId: 'no-source', status: 'skipped', title: 'No source' },
|
||||
{ playlistId: 'reachable', status: 'updated', title: 'reachable' },
|
||||
]);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Skipping playlist "No source": no URL or file path found'
|
||||
);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
AutoUpdatePlaylistStatus,
|
||||
AutoUpdatePlaylistsResult,
|
||||
Playlist,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import {
|
||||
fetchPlaylistFromFile,
|
||||
@@ -57,12 +61,19 @@ async function refreshPlaylist(
|
||||
* Refreshes the given playlists with bounded concurrency, isolating every
|
||||
* failure so one broken source never withholds the playlists that did update.
|
||||
* The returned playlists keep the order of the requested ones.
|
||||
*
|
||||
* Because failures are isolated, they are also reported: every requested
|
||||
* playlist gets an outcome, so the renderer can tell the user what actually
|
||||
* happened instead of assuming the whole run succeeded.
|
||||
*/
|
||||
export async function autoUpdatePlaylists(
|
||||
playlists: readonly Playlist[],
|
||||
options: PlaylistFetchOptions = {}
|
||||
): Promise<Playlist[]> {
|
||||
): Promise<AutoUpdatePlaylistsResult> {
|
||||
const results: (Playlist | null)[] = new Array(playlists.length).fill(null);
|
||||
const statuses: (AutoUpdatePlaylistStatus | undefined)[] = new Array(
|
||||
playlists.length
|
||||
);
|
||||
let nextIndex = 0;
|
||||
|
||||
const refreshNextPlaylist = async (): Promise<void> => {
|
||||
@@ -74,12 +85,15 @@ export async function autoUpdatePlaylists(
|
||||
const playlist = playlists[index];
|
||||
|
||||
try {
|
||||
results[index] = await refreshPlaylist(playlist, options);
|
||||
const refreshed = await refreshPlaylist(playlist, options);
|
||||
results[index] = refreshed;
|
||||
statuses[index] = refreshed ? 'updated' : 'skipped';
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to update playlist "${playlist.title}":`,
|
||||
error
|
||||
);
|
||||
statuses[index] = 'failed';
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -91,7 +105,16 @@ export async function autoUpdatePlaylists(
|
||||
)
|
||||
);
|
||||
|
||||
return results.filter(
|
||||
(playlist): playlist is Playlist => playlist !== null
|
||||
);
|
||||
return {
|
||||
playlists: results.filter(
|
||||
(playlist): playlist is Playlist => playlist !== null
|
||||
),
|
||||
outcomes: playlists.map((playlist, index) => ({
|
||||
playlistId: playlist._id,
|
||||
title: playlist.title,
|
||||
// Every index is visited above; an unresolved slot could only mean
|
||||
// the refresh never completed, which is a failure, not a success.
|
||||
status: statuses[index] ?? 'failed',
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AutoUpdatePlaylistsResult,
|
||||
Playlist,
|
||||
PlaylistRefreshEvent,
|
||||
PlaylistRefreshPayload,
|
||||
@@ -309,12 +310,12 @@ describe('playlist IPC events', () => {
|
||||
})
|
||||
);
|
||||
|
||||
const result = await getHandler(AUTO_UPDATE_PLAYLISTS)(
|
||||
const result = (await getHandler(AUTO_UPDATE_PLAYLISTS)(
|
||||
createIpcEvent(),
|
||||
sourcePlaylists
|
||||
);
|
||||
)) as AutoUpdatePlaylistsResult;
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result.playlists).toEqual([
|
||||
expect.objectContaining({
|
||||
_id: 'url-playlist',
|
||||
autoRefresh: true,
|
||||
@@ -329,6 +330,23 @@ describe('playlist IPC events', () => {
|
||||
title: 'Updated file playlist',
|
||||
}),
|
||||
]);
|
||||
expect(result.outcomes).toEqual([
|
||||
{
|
||||
playlistId: 'url-playlist',
|
||||
status: 'updated',
|
||||
title: 'URL playlist',
|
||||
},
|
||||
{
|
||||
playlistId: 'file-playlist',
|
||||
status: 'updated',
|
||||
title: 'File playlist',
|
||||
},
|
||||
{
|
||||
playlistId: 'missing-source',
|
||||
status: 'skipped',
|
||||
title: 'Missing source',
|
||||
},
|
||||
]);
|
||||
expect(mockAxiosGet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpsAgent: expect.any(Object),
|
||||
@@ -347,6 +365,61 @@ describe('playlist IPC events', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reports failed playlists as failed outcomes instead of dropping them silently', async () => {
|
||||
const sourcePlaylists: Playlist[] = [
|
||||
createPlaylist({
|
||||
_id: 'unreachable-playlist',
|
||||
filePath: undefined,
|
||||
title: 'Unreachable playlist',
|
||||
url: 'https://unreachable.test/list.m3u',
|
||||
}),
|
||||
createPlaylist({
|
||||
_id: 'file-playlist',
|
||||
filePath: '/playlists/local.m3u',
|
||||
importDate: '',
|
||||
title: 'File playlist',
|
||||
url: undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
mockAxiosGet.mockRejectedValue(
|
||||
new Error('timeout of 30000ms exceeded')
|
||||
);
|
||||
mockReadFile.mockResolvedValue('#EXTM3U file');
|
||||
mockParse.mockReturnValue({ items: [{ name: 'Updated' }] });
|
||||
mockCreatePlaylistObject.mockReturnValue(
|
||||
createPlaylist({
|
||||
_id: 'new-file-playlist',
|
||||
filePath: '/playlists/local.m3u',
|
||||
title: 'Updated file playlist',
|
||||
})
|
||||
);
|
||||
|
||||
const result = (await getHandler(AUTO_UPDATE_PLAYLISTS)(
|
||||
createIpcEvent(),
|
||||
sourcePlaylists
|
||||
)) as AutoUpdatePlaylistsResult;
|
||||
|
||||
expect(result.playlists).toEqual([
|
||||
expect.objectContaining({
|
||||
_id: 'file-playlist',
|
||||
title: 'Updated file playlist',
|
||||
}),
|
||||
]);
|
||||
expect(result.outcomes).toEqual([
|
||||
{
|
||||
playlistId: 'unreachable-playlist',
|
||||
status: 'failed',
|
||||
title: 'Unreachable playlist',
|
||||
},
|
||||
{
|
||||
playlistId: 'file-playlist',
|
||||
status: 'updated',
|
||||
title: 'File playlist',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards playlist refresh worker events, resolves successful responses, and cleans up the worker', async () => {
|
||||
const ipcEvent = createIpcEvent();
|
||||
const payload: PlaylistRefreshPayload = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Playlist,
|
||||
PlaylistRefreshEvent,
|
||||
PlaylistRefreshPayload,
|
||||
summarizeAutoUpdateOutcomes,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths';
|
||||
import type {
|
||||
@@ -152,14 +153,15 @@ ipcMain.handle(
|
||||
) => {
|
||||
console.log(`Auto-updating ${playlists.length} playlist(s)...`);
|
||||
|
||||
const updatedPlaylists = await autoUpdatePlaylists(playlists, {
|
||||
const result = await autoUpdatePlaylists(playlists, {
|
||||
trustedInsecureTlsHosts: options?.trustedInsecureTlsHosts,
|
||||
});
|
||||
const summary = summarizeAutoUpdateOutcomes(result.outcomes);
|
||||
|
||||
console.log(
|
||||
`Auto-update completed: ${updatedPlaylists.length} updated`
|
||||
`Auto-update completed: ${summary.updated} updated, ${summary.failed} failed, ${summary.skipped} skipped`
|
||||
);
|
||||
return updatedPlaylists;
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
AutoUpdatePlaylistsResult,
|
||||
AutoUpdatePlaylistsSummary,
|
||||
summarizeAutoUpdateOutcomes,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const AUTO_UPDATE_PLAYLISTS_MESSAGE_KEYS = {
|
||||
failed: 'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_FAILED',
|
||||
partial: 'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_PARTIAL',
|
||||
partialWithSkipped:
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED',
|
||||
skipped: 'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SKIPPED',
|
||||
success: 'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS',
|
||||
} as const;
|
||||
|
||||
export interface AutoUpdatePlaylistsFeedback {
|
||||
messageKey: string;
|
||||
params: AutoUpdatePlaylistsSummary;
|
||||
/** Failures deserve a dismissable, longer-lived snackbar. */
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an auto-refresh result into the snackbar message the user should see.
|
||||
* The backend isolates per-playlist failures, so "some playlists were updated"
|
||||
* must never be reported as "all playlists were updated".
|
||||
*/
|
||||
export function buildAutoUpdatePlaylistsFeedback(
|
||||
result: AutoUpdatePlaylistsResult
|
||||
): AutoUpdatePlaylistsFeedback {
|
||||
const summary = summarizeAutoUpdateOutcomes(result.outcomes);
|
||||
|
||||
if (summary.failed > 0) {
|
||||
return {
|
||||
messageKey: resolveFailureMessageKey(summary),
|
||||
params: summary,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (summary.skipped > 0) {
|
||||
return {
|
||||
messageKey: AUTO_UPDATE_PLAYLISTS_MESSAGE_KEYS.skipped,
|
||||
params: summary,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
messageKey: AUTO_UPDATE_PLAYLISTS_MESSAGE_KEYS.success,
|
||||
params: summary,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every playlist the user was not told about looks like an unexplained
|
||||
* remainder, so a batch that both failed and skipped needs a message that
|
||||
* accounts for all three counts — `partial` alone leaves `total` unreconciled.
|
||||
*/
|
||||
function resolveFailureMessageKey(summary: AutoUpdatePlaylistsSummary): string {
|
||||
if (summary.updated === 0) {
|
||||
return AUTO_UPDATE_PLAYLISTS_MESSAGE_KEYS.failed;
|
||||
}
|
||||
|
||||
return summary.skipped > 0
|
||||
? AUTO_UPDATE_PLAYLISTS_MESSAGE_KEYS.partialWithSkipped
|
||||
: AUTO_UPDATE_PLAYLISTS_MESSAGE_KEYS.partial;
|
||||
}
|
||||
@@ -4,10 +4,16 @@ import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { DialogService } from '@iptvnator/ui/components';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
AUTO_UPDATE_PLAYLISTS,
|
||||
AutoUpdatePlaylistOutcome,
|
||||
AutoUpdatePlaylistStatus,
|
||||
AutoUpdatePlaylistsResult,
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
Playlist,
|
||||
SECURITY_ERROR_PREFIX,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { ElectronService } from './electron.service';
|
||||
@@ -15,19 +21,24 @@ import { ElectronService } from './electron.service';
|
||||
describe('ElectronService', () => {
|
||||
const session = { id: 'session-1' };
|
||||
let electronBridge: {
|
||||
autoUpdatePlaylists: jest.Mock;
|
||||
fetchPlaylistByUrl: jest.Mock;
|
||||
onPlayerError: jest.Mock;
|
||||
openInMpv: jest.Mock;
|
||||
openInVlc: jest.Mock;
|
||||
};
|
||||
let snackBar: { open: jest.Mock };
|
||||
let store: { dispatch: jest.Mock };
|
||||
let translateService: { instant: jest.Mock };
|
||||
let service: ElectronService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
electronBridge = {
|
||||
autoUpdatePlaylists: jest.fn(),
|
||||
fetchPlaylistByUrl: jest.fn(),
|
||||
onPlayerError: jest.fn(),
|
||||
openInMpv: jest.fn().mockResolvedValue(session),
|
||||
@@ -38,6 +49,8 @@ describe('ElectronService', () => {
|
||||
onAction: () => of(undefined),
|
||||
})),
|
||||
};
|
||||
store = { dispatch: jest.fn() };
|
||||
translateService = { instant: jest.fn((key: string) => key) };
|
||||
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
@@ -72,15 +85,11 @@ describe('ElectronService', () => {
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: {
|
||||
dispatch: jest.fn(),
|
||||
},
|
||||
useValue: store,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
},
|
||||
useValue: translateService,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -112,9 +121,7 @@ describe('ElectronService', () => {
|
||||
originalError: `Request failed: token=${secret}&channel=news`,
|
||||
});
|
||||
|
||||
const output = JSON.stringify(
|
||||
(console.error as jest.Mock).mock.calls
|
||||
);
|
||||
const output = JSON.stringify((console.error as jest.Mock).mock.calls);
|
||||
expect(output).not.toContain(secret);
|
||||
expect(output).toContain('channel=news');
|
||||
});
|
||||
@@ -190,4 +197,158 @@ describe('ElectronService', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('startup playlist auto-refresh feedback', () => {
|
||||
function createPlaylist(id: string, title: string): Playlist {
|
||||
return {
|
||||
_id: id,
|
||||
autoRefresh: true,
|
||||
count: 1,
|
||||
importDate: '2026-07-01T00:00:00.000Z',
|
||||
lastUsage: '2026-07-01T00:00:00.000Z',
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
function outcome(
|
||||
playlistId: string,
|
||||
title: string,
|
||||
status: AutoUpdatePlaylistStatus
|
||||
): AutoUpdatePlaylistOutcome {
|
||||
return { playlistId, status, title };
|
||||
}
|
||||
|
||||
async function runAutoUpdate(
|
||||
result: AutoUpdatePlaylistsResult
|
||||
): Promise<void> {
|
||||
electronBridge.autoUpdatePlaylists.mockResolvedValue(result);
|
||||
|
||||
await service.sendIpcEvent(
|
||||
AUTO_UPDATE_PLAYLISTS,
|
||||
result.outcomes.map((entry) =>
|
||||
createPlaylist(entry.playlistId, entry.title)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
it('reports success only when every requested playlist was refreshed', async () => {
|
||||
const refreshed = [
|
||||
createPlaylist('first', 'First'),
|
||||
createPlaylist('second', 'Second'),
|
||||
];
|
||||
|
||||
await runAutoUpdate({
|
||||
playlists: refreshed,
|
||||
outcomes: [
|
||||
outcome('first', 'First', 'updated'),
|
||||
outcome('second', 'Second', 'updated'),
|
||||
],
|
||||
});
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updateManyPlaylists({ playlists: refreshed })
|
||||
);
|
||||
expect(translateService.instant).toHaveBeenCalledWith(
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS',
|
||||
{ total: 2, updated: 2, failed: 0, skipped: 0 }
|
||||
);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS',
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('says the refresh was incomplete when only some playlists updated', async () => {
|
||||
const refreshed = [createPlaylist('reachable', 'Reachable')];
|
||||
|
||||
await runAutoUpdate({
|
||||
playlists: refreshed,
|
||||
outcomes: [
|
||||
outcome('reachable', 'Reachable', 'updated'),
|
||||
outcome('unreachable', 'Unreachable', 'failed'),
|
||||
],
|
||||
});
|
||||
|
||||
// Only the successfully refreshed playlists reach the store.
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updateManyPlaylists({ playlists: refreshed })
|
||||
);
|
||||
expect(translateService.instant).toHaveBeenCalledWith(
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_PARTIAL',
|
||||
{ total: 2, updated: 1, failed: 1, skipped: 0 }
|
||||
);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_PARTIAL',
|
||||
'CLOSE',
|
||||
{ duration: 6000, panelClass: ['error-snackbar'] }
|
||||
);
|
||||
expect(
|
||||
JSON.stringify((console.warn as jest.Mock).mock.calls)
|
||||
).toContain('Unreachable (failed)');
|
||||
});
|
||||
|
||||
it('accounts for skipped playlists in a batch that also failed', async () => {
|
||||
await runAutoUpdate({
|
||||
playlists: [createPlaylist('reachable', 'Reachable')],
|
||||
outcomes: [
|
||||
outcome('reachable', 'Reachable', 'updated'),
|
||||
outcome('unreachable', 'Unreachable', 'failed'),
|
||||
outcome('sourceless', 'Sourceless', 'skipped'),
|
||||
],
|
||||
});
|
||||
|
||||
// The plain partial message only names updated/total/failed, which
|
||||
// would leave the skipped playlist unexplained.
|
||||
expect(translateService.instant).toHaveBeenCalledWith(
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED',
|
||||
{ total: 3, updated: 1, failed: 1, skipped: 1 }
|
||||
);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED',
|
||||
'CLOSE',
|
||||
{ duration: 6000, panelClass: ['error-snackbar'] }
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a full failure when no playlist could be refreshed', async () => {
|
||||
await runAutoUpdate({
|
||||
playlists: [],
|
||||
outcomes: [
|
||||
outcome('first', 'First', 'failed'),
|
||||
outcome('second', 'Second', 'failed'),
|
||||
],
|
||||
});
|
||||
|
||||
expect(translateService.instant).toHaveBeenCalledWith(
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_FAILED',
|
||||
{ total: 2, updated: 0, failed: 2, skipped: 0 }
|
||||
);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_FAILED',
|
||||
'CLOSE',
|
||||
{ duration: 6000, panelClass: ['error-snackbar'] }
|
||||
);
|
||||
});
|
||||
|
||||
it('distinguishes playlists skipped for a missing source from failures', async () => {
|
||||
await runAutoUpdate({
|
||||
playlists: [createPlaylist('reachable', 'Reachable')],
|
||||
outcomes: [
|
||||
outcome('reachable', 'Reachable', 'updated'),
|
||||
outcome('sourceless', 'Sourceless', 'skipped'),
|
||||
],
|
||||
});
|
||||
|
||||
expect(translateService.instant).toHaveBeenCalledWith(
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SKIPPED',
|
||||
{ total: 2, updated: 1, failed: 0, skipped: 1 }
|
||||
);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SKIPPED',
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { DialogService } from '@iptvnator/ui/components';
|
||||
import { DataService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
AUTO_UPDATE_PLAYLISTS,
|
||||
AutoUpdatePlaylistsResult,
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
|
||||
ERROR,
|
||||
normalizeHost,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
XtreamCodeActions,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { AppConfig } from '../../environments/environment';
|
||||
import { buildAutoUpdatePlaylistsFeedback } from './auto-update-playlists-feedback';
|
||||
import {
|
||||
createLogger,
|
||||
createPortalDebugRequestContext,
|
||||
@@ -220,29 +222,50 @@ export class ElectronService extends DataService {
|
||||
|
||||
if (type === AUTO_UPDATE_PLAYLISTS) {
|
||||
const data = payload as Playlist[];
|
||||
const playlists = await window.electron.autoUpdatePlaylists(
|
||||
const result = await window.electron.autoUpdatePlaylists(
|
||||
data,
|
||||
this.settingsStore.getTrustOptions()
|
||||
);
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updateManyPlaylists({
|
||||
playlists,
|
||||
playlists: result.playlists,
|
||||
})
|
||||
);
|
||||
this.snackBar.open(
|
||||
this.translateService.instant(
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
return playlists as T;
|
||||
this.reportAutoUpdatePlaylistsResult(result);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
this.logger.debug('Unknown IPC event type:', type);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
private reportAutoUpdatePlaylistsResult(
|
||||
result: AutoUpdatePlaylistsResult
|
||||
): void {
|
||||
const unresolved = result.outcomes.filter(
|
||||
(outcome) => outcome.status !== 'updated'
|
||||
);
|
||||
if (unresolved.length > 0) {
|
||||
this.logger.warn(
|
||||
'Playlist auto-refresh did not update every playlist:',
|
||||
unresolved
|
||||
.map((outcome) => `${outcome.title} (${outcome.status})`)
|
||||
.join(', ')
|
||||
);
|
||||
}
|
||||
|
||||
const feedback = buildAutoUpdatePlaylistsFeedback(result);
|
||||
this.snackBar.open(
|
||||
this.translateService.instant(feedback.messageKey, feedback.params),
|
||||
feedback.isError
|
||||
? this.translateService.instant('CLOSE')
|
||||
: undefined,
|
||||
feedback.isError
|
||||
? { duration: 6000, panelClass: ['error-snackbar'] }
|
||||
: { duration: 2000 }
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchStalkerData(payload: {
|
||||
url: string;
|
||||
macAddress: string;
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "فشل تحديث قائمة التشغيل. الملف المحلي لم يعد متاحًا. تحقق من مسار الملف أو أعد استيراد قائمة التشغيل.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "فشل تحديث قائمة التشغيل. لم يعد بإمكان التطبيق الوصول إلى الملف المحلي.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "نجاح! تم تحديث قوائم التشغيل بنجاح",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "التحديث التلقائي غير مكتمل: تم تحديث {{updated}} من {{total}} من قوائم التشغيل، وفشل {{failed}}.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "التحديث التلقائي غير مكتمل: تم تحديث {{updated}} من {{total}} من قوائم التشغيل، وفشل {{failed}}، وتم تخطي {{skipped}} (بدون رابط أو مسار ملف).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "فشل التحديث التلقائي لقوائم التشغيل. لم يتم تحديث أي من قوائم التشغيل الـ {{total}}.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "التحديث التلقائي لقوائم التشغيل: تم تحديث {{updated}} من {{total}}، وتم تخطي {{skipped}} (بدون رابط أو مسار ملف).",
|
||||
"AUTO_REFRESH_ENABLED": "تم تفعيل التحديث التلقائي"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "فشل تحديث قائمة التشغيل. الملف المحلي ما بقاش متوفر. تحقق من مسار الملف ولا استورد قائمة التشغيل من جديد.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "فشل تحديث قائمة التشغيل. التطبيق ما قدرش يوصل للملف المحلي.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "نجاح! قوائم التشغيل تحدثات بنجاح",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "التحديث التلقائي ما كملش: تحدّاو {{updated}} من {{total}} ديال قوائم التشغيل، و{{failed}} فشلو.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "التحديث التلقائي ما كملش: تحدّاو {{updated}} من {{total}} ديال قوائم التشغيل، {{failed}} فشلو، و{{skipped}} تخطاو (بلا رابط ولا مسار ملف).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "فشل التحديث التلقائي ديال قوائم التشغيل. حتى وحدة من {{total}} ما تحدّاتش.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "التحديث التلقائي ديال قوائم التشغيل: تحدّاو {{updated}} من {{total}}، و{{skipped}} تخطاو (بلا رابط ولا مسار ملف).",
|
||||
"AUTO_REFRESH_ENABLED": "التحديث التلقائي مفعل"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "Не ўдалося абнавіць плэйліст. Лакальны файл больш недаступны. Праверце шлях да файла або паўторна імпартуйце плэйліст.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Не ўдалося абнавіць плэйліст. Праграма больш не мае доступу да лакальнага файла.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Гатова! Плэйлісты былі паспяхова абноўлены",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "Аўтаабнаўленне не завершана: абноўлена {{updated}} з {{total}} плэйлістоў, не ўдалося — {{failed}}.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "Аўтаабнаўленне не завершана: абноўлена {{updated}} з {{total}} плэйлістоў, не ўдалося — {{failed}}, прапушчана {{skipped}} (няма URL або шляху да файла).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "Аўтаабнаўленне плэйлістоў не ўдалося. Ніводзін з {{total}} плэйлістоў не быў абноўлены.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "Аўтаабнаўленне плэйлістоў: абноўлена {{updated}} з {{total}}, прапушчана {{skipped}} (няма URL або шляху да файла).",
|
||||
"AUTO_REFRESH_ENABLED": "Аўтаабнаўленне ўключана"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "Aktualisierung der Playlist fehlgeschlagen. Die lokale Datei ist nicht mehr verfügbar. Überprüfen Sie den Dateipfad oder importieren Sie die Playlist erneut.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Aktualisierung der Playlist fehlgeschlagen. Die App kann nicht mehr auf die lokale Datei zugreifen.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Erfolg! Die Playlists wurden erfolgreich aktualisiert",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "Auto-Refresh unvollständig: {{updated}} von {{total}} Playlists aktualisiert, {{failed}} fehlgeschlagen.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "Auto-Refresh unvollständig: {{updated}} von {{total}} Playlists aktualisiert, {{failed}} fehlgeschlagen, {{skipped}} übersprungen (keine URL und kein Dateipfad).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "Auto-Refresh der Playlists fehlgeschlagen. Keine der {{total}} Playlists konnte aktualisiert werden.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "Auto-Refresh der Playlists: {{updated}} von {{total}} aktualisiert, {{skipped}} übersprungen (keine URL und kein Dateipfad).",
|
||||
"AUTO_REFRESH_ENABLED": "Auto-Refresh aktiviert"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "Η ανανέωση της λίστας αναπαραγωγής απέτυχε. Το τοπικό αρχείο δεν είναι πλέον διαθέσιμο. Ελέγξτε τη διαδρομή του αρχείου ή εισαγάγετε ξανά τη λίστα αναπαραγωγής.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Η ανανέωση της λίστας αναπαραγωγής απέτυχε. Η εφαρμογή δεν έχει πλέον πρόσβαση στο τοπικό αρχείο.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Επιτυχία! Οι λίστες αναπαραγωγής ενημερώθηκαν με επιτυχία",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "Η αυτόματη ανανέωση δεν ολοκληρώθηκε: ενημερώθηκαν {{updated}} από {{total}} λίστες αναπαραγωγής, {{failed}} απέτυχαν.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "Η αυτόματη ανανέωση δεν ολοκληρώθηκε: ενημερώθηκαν {{updated}} από {{total}} λίστες αναπαραγωγής, {{failed}} απέτυχαν, {{skipped}} παραλείφθηκαν (χωρίς URL ή διαδρομή αρχείου).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "Η αυτόματη ανανέωση των λιστών αναπαραγωγής απέτυχε. Καμία από τις {{total}} λίστες δεν ενημερώθηκε.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "Αυτόματη ανανέωση λιστών αναπαραγωγής: ενημερώθηκαν {{updated}} από {{total}}, παραλείφθηκαν {{skipped}} (χωρίς URL ή διαδρομή αρχείου).",
|
||||
"AUTO_REFRESH_ENABLED": "Η αυτόματη ανανέωση είναι ενεργοποιημένη"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "Playlist refresh failed. The local file is no longer available. Check the file path or re-import the playlist.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Playlist refresh failed. The app can no longer access the local file.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Success! The playlists were successfully updated",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "Playlist auto-refresh incomplete: {{updated}} of {{total}} playlists updated, {{failed}} failed.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "Playlist auto-refresh incomplete: {{updated}} of {{total}} playlists updated, {{failed}} failed, {{skipped}} skipped (no URL or file path).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "Playlist auto-refresh failed. None of the {{total}} playlists could be updated.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "Playlist auto-refresh: {{updated}} of {{total}} playlists updated, {{skipped}} skipped (no URL or file path).",
|
||||
"AUTO_REFRESH_ENABLED": "Auto-refresh enabled"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "No se pudo actualizar la lista. El archivo local ya no está disponible. Verifica la ruta del archivo o vuelve a importar la lista.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "No se pudo actualizar la lista. La aplicación ya no puede acceder al archivo local.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "¡Listo! Las listas de reproducción se actualizaron correctamente",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "Auto-actualización incompleta: se actualizaron {{updated}} de {{total}} listas de reproducción, {{failed}} fallaron.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "Auto-actualización incompleta: se actualizaron {{updated}} de {{total}} listas de reproducción, {{failed}} fallaron, {{skipped}} omitidas (sin URL ni ruta de archivo).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "La auto-actualización de listas de reproducción falló. Ninguna de las {{total}} listas se pudo actualizar.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "Auto-actualización de listas de reproducción: se actualizaron {{updated}} de {{total}}, {{skipped}} omitidas (sin URL ni ruta de archivo).",
|
||||
"AUTO_REFRESH_ENABLED": "Auto-actualización activada"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "Échec de l'actualisation de la liste de lecture. Le fichier local n'est plus disponible. Vérifiez le chemin du fichier ou réimportez la liste de lecture.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Échec de l'actualisation de la liste de lecture. L'application ne peut plus accéder au fichier local.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Succès ! Les listes de lecture ont été mises à jour avec succès",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "Actualisation automatique incomplète : {{updated}} listes de lecture sur {{total}} mises à jour, {{failed}} en échec.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "Actualisation automatique incomplète : {{updated}} listes de lecture sur {{total}} mises à jour, {{failed}} en échec, {{skipped}} ignorées (aucune URL ni chemin de fichier).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "Échec de l'actualisation automatique. Aucune des {{total}} listes de lecture n'a pu être mise à jour.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "Actualisation automatique des listes de lecture : {{updated}} sur {{total}} mises à jour, {{skipped}} ignorées (aucune URL ni chemin de fichier).",
|
||||
"AUTO_REFRESH_ENABLED": "Actualisation automatique activée"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "Aggiornamento della playlist non riuscito. Il file locale non è più disponibile. Controlla il percorso del file o reimporta la playlist.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Aggiornamento della playlist non riuscito. L'app non può più accedere al file locale.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Le playlist sono state aggiornate correttamente",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "Aggiornamento automatico incompleto: aggiornate {{updated}} playlist su {{total}}, {{failed}} non riuscite.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "Aggiornamento automatico incompleto: aggiornate {{updated}} playlist su {{total}}, {{failed}} non riuscite, {{skipped}} ignorate (nessun URL o percorso file).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "Aggiornamento automatico delle playlist non riuscito. Nessuna delle {{total}} playlist è stata aggiornata.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "Aggiornamento automatico delle playlist: aggiornate {{updated}} su {{total}}, {{skipped}} ignorate (nessun URL o percorso file).",
|
||||
"AUTO_REFRESH_ENABLED": "Aggiornamento automatico attivo"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "プレイリストの更新に失敗しました。ローカルファイルが利用できなくなっています。ファイルパスを確認するか、プレイリストを再インポートしてください。",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "プレイリストの更新に失敗しました。アプリがローカルファイルにアクセスできなくなりました。",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "成功!プレイリストが正常に更新されました",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "プレイリストの自動更新が完了しませんでした:{{total}} 件中 {{updated}} 件を更新し、{{failed}} 件が失敗しました。",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "プレイリストの自動更新が完了しませんでした:{{total}} 件中 {{updated}} 件を更新し、{{failed}} 件が失敗、{{skipped}} 件をスキップしました(URL もファイルパスもありません)。",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "プレイリストの自動更新に失敗しました。{{total}} 件のプレイリストをいずれも更新できませんでした。",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "プレイリストの自動更新:{{total}} 件中 {{updated}} 件を更新し、{{skipped}} 件をスキップしました(URL もファイルパスもありません)。",
|
||||
"AUTO_REFRESH_ENABLED": "自動更新が有効です"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "재생목록 새로 고침에 실패했습니다. 로컬 파일을 더 이상 사용할 수 없습니다. 파일 경로를 확인하거나 재생목록을 다시 가져오세요.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "재생목록 새로 고침에 실패했습니다. 앱이 더 이상 로컬 파일에 접근할 수 없습니다.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "성공! 재생목록이 성공적으로 업데이트되었습니다",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "재생목록 자동 새로 고침이 완료되지 않았습니다. {{total}}개 중 {{updated}}개를 업데이트했고 {{failed}}개가 실패했습니다.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "재생목록 자동 새로 고침이 완료되지 않았습니다. {{total}}개 중 {{updated}}개를 업데이트했고 {{failed}}개가 실패했으며 {{skipped}}개를 건너뛰었습니다(URL 또는 파일 경로 없음).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "재생목록 자동 새로 고침에 실패했습니다. {{total}}개의 재생목록을 모두 업데이트하지 못했습니다.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "재생목록 자동 새로 고침: {{total}}개 중 {{updated}}개를 업데이트하고 {{skipped}}개를 건너뛰었습니다(URL 또는 파일 경로 없음).",
|
||||
"AUTO_REFRESH_ENABLED": "자동 새로 고침 활성화됨"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "Vernieuwen van afspeellijst mislukt. Het lokale bestand is niet meer beschikbaar. Controleer het bestandspad of importeer de afspeellijst opnieuw.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Vernieuwen van afspeellijst mislukt. De app heeft geen toegang meer tot het lokale bestand.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Gelukt! De afspeellijsten zijn succesvol bijgewerkt",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "Automatisch vernieuwen onvolledig: {{updated}} van {{total}} afspeellijsten bijgewerkt, {{failed}} mislukt.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "Automatisch vernieuwen onvolledig: {{updated}} van {{total}} afspeellijsten bijgewerkt, {{failed}} mislukt, {{skipped}} overgeslagen (geen URL of bestandspad).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "Automatisch vernieuwen van afspeellijsten mislukt. Geen van de {{total}} afspeellijsten kon worden bijgewerkt.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "Automatisch vernieuwen van afspeellijsten: {{updated}} van {{total}} bijgewerkt, {{skipped}} overgeslagen (geen URL of bestandspad).",
|
||||
"AUTO_REFRESH_ENABLED": "Automatisch vernieuwen ingeschakeld"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "Odświeżanie listy odtwarzania nie powiodło się. Plik lokalny jest niedostępny. Sprawdź ścieżkę pliku lub zaimportuj listę ponownie.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Odświeżanie listy odtwarzania nie powiodło się. Aplikacja nie ma już dostępu do pliku lokalnego.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Sukces! Listy odtwarzania zostały pomyślnie zaktualizowane",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "Automatyczne odświeżanie niekompletne: zaktualizowano {{updated}} z {{total}} list odtwarzania, {{failed}} nie powiodło się.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "Automatyczne odświeżanie niekompletne: zaktualizowano {{updated}} z {{total}} list odtwarzania, {{failed}} nie powiodło się, pominięto {{skipped}} (brak adresu URL lub ścieżki pliku).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "Automatyczne odświeżanie list odtwarzania nie powiodło się. Nie zaktualizowano żadnej z {{total}} list.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "Automatyczne odświeżanie list odtwarzania: zaktualizowano {{updated}} z {{total}}, pominięto {{skipped}} (brak adresu URL lub ścieżki pliku).",
|
||||
"AUTO_REFRESH_ENABLED": "Automatyczne odświeżanie włączone"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "Falha ao atualizar a playlist. O arquivo local não está mais disponível. Verifique o caminho do arquivo ou reimporte a playlist.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Falha ao atualizar a playlist. O aplicativo não consegue mais acessar o arquivo local.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Sucesso! As playlists foram atualizadas com sucesso",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "Atualização automática incompleta: {{updated}} de {{total}} playlists atualizadas, {{failed}} falharam.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "Atualização automática incompleta: {{updated}} de {{total}} playlists atualizadas, {{failed}} falharam, {{skipped}} ignoradas (sem URL ou caminho de arquivo).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "Falha na atualização automática das playlists. Nenhuma das {{total}} playlists pôde ser atualizada.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "Atualização automática de playlists: {{updated}} de {{total}} atualizadas, {{skipped}} ignoradas (sem URL ou caminho de arquivo).",
|
||||
"AUTO_REFRESH_ENABLED": "Atualização automática ativada"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "Не удалось обновить плейлист. Локальный файл больше не доступен. Проверьте путь к файлу или импортируйте плейлист заново.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Не удалось обновить плейлист. Приложение больше не имеет доступа к локальному файлу.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Успешно! Плейлисты были успешно обновлены",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "Автообновление выполнено не полностью: обновлено {{updated}} из {{total}} плейлистов, не удалось — {{failed}}.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "Автообновление выполнено не полностью: обновлено {{updated}} из {{total}} плейлистов, не удалось — {{failed}}, пропущено {{skipped}} (нет URL или пути к файлу).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "Не удалось выполнить автообновление плейлистов. Ни один из {{total}} плейлистов не был обновлён.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "Автообновление плейлистов: обновлено {{updated}} из {{total}}, пропущено {{skipped}} (нет URL или пути к файлу).",
|
||||
"AUTO_REFRESH_ENABLED": "Автообновление включено"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "Oynatma listesi yenileme başarısız oldu. Yerel dosya artık kullanılabilir değil. Dosya yolunu kontrol edin veya oynatma listesini yeniden içe aktarın.",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Oynatma listesi yenileme başarısız oldu. Uygulama artık yerel dosyaya erişemiyor.",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "Başarılı! Oynatma listeleri başarıyla güncellendi",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "Otomatik yenileme tamamlanmadı: {{total}} oynatma listesinden {{updated}} tanesi güncellendi, {{failed}} tanesi başarısız oldu.",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "Otomatik yenileme tamamlanmadı: {{total}} oynatma listesinden {{updated}} tanesi güncellendi, {{failed}} tanesi başarısız oldu, {{skipped}} tanesi atlandı (URL veya dosya yolu yok).",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "Oynatma listelerinin otomatik yenilenmesi başarısız oldu. {{total}} oynatma listesinin hiçbiri güncellenemedi.",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "Oynatma listesi otomatik yenileme: {{total}} listeden {{updated}} tanesi güncellendi, {{skipped}} tanesi atlandı (URL veya dosya yolu yok).",
|
||||
"AUTO_REFRESH_ENABLED": "Otomatik yenileme etkin"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "播放列表刷新失败。本地文件已不可用。请检查文件路径或重新导入播放列表。",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "播放列表刷新失败。应用程序无法再访问该本地文件。",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "成功!播放列表已成功更新",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "播放列表自动刷新未完成:{{total}} 个播放列表中已更新 {{updated}} 个,{{failed}} 个失败。",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "播放列表自动刷新未完成:{{total}} 个播放列表中已更新 {{updated}} 个,{{failed}} 个失败,跳过 {{skipped}} 个(没有 URL 或文件路径)。",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "播放列表自动刷新失败。{{total}} 个播放列表均未更新。",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "播放列表自动刷新:{{total}} 个中已更新 {{updated}} 个,跳过 {{skipped}} 个(没有 URL 或文件路径)。",
|
||||
"AUTO_REFRESH_ENABLED": "已启用自动刷新"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "播放清單重新整理失敗。本機檔案已不存在。請檢查檔案路徑或重新匯入播放清單。",
|
||||
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "播放清單重新整理失敗。應用程式無法再存取本機檔案。",
|
||||
"AUTO_REFRESH_UPDATE_SUCCESS": "成功!播放清單已成功更新",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL": "播放清單自動重新整理未完成:{{total}} 個播放清單中已更新 {{updated}} 個,{{failed}} 個失敗。",
|
||||
"AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED": "播放清單自動重新整理未完成:{{total}} 個播放清單中已更新 {{updated}} 個,{{failed}} 個失敗,略過 {{skipped}} 個(沒有 URL 或檔案路徑)。",
|
||||
"AUTO_REFRESH_UPDATE_FAILED": "播放清單自動重新整理失敗。{{total}} 個播放清單皆未更新。",
|
||||
"AUTO_REFRESH_UPDATE_SKIPPED": "播放清單自動重新整理:{{total}} 個中已更新 {{updated}} 個,略過 {{skipped}} 個(沒有 URL 或檔案路徑)。",
|
||||
"AUTO_REFRESH_ENABLED": "已啟用自動重新整理"
|
||||
},
|
||||
"ADD_PLAYLIST": {
|
||||
|
||||
@@ -86,6 +86,35 @@ dead source must never stall startup (issue #931):
|
||||
so refresh logging goes through `redactSensitiveData()` from
|
||||
`@iptvnator/shared/logging`.
|
||||
|
||||
### Reporting The Auto-Update Result
|
||||
|
||||
Because auto-update isolates failures, it must also report them — otherwise a
|
||||
dropped playlist is indistinguishable from a successful refresh. `autoUpdatePlaylists()`
|
||||
returns `AutoUpdatePlaylistsResult`
|
||||
(`libs/shared/interfaces/src/lib/playlist-auto-update.interface.ts`): the refreshed
|
||||
`playlists` plus one `outcome` per requested playlist, in request order.
|
||||
|
||||
| Outcome status | Meaning |
|
||||
| -------------- | ------------------------------------------------------------------------------- |
|
||||
| `updated` | Source fetched/read and parsed; playlist is in `playlists` |
|
||||
| `failed` | Source unreachable, unreadable or unparsable |
|
||||
| `skipped` | Playlist has neither a URL nor a file path, so there is nothing to refresh from |
|
||||
|
||||
`ElectronService` dispatches `updateManyPlaylists` with the refreshed playlists only,
|
||||
logs the titles of unresolved playlists, then derives the snackbar from those outcomes
|
||||
(`apps/web/src/app/services/auto-update-playlists-feedback.ts`):
|
||||
`AUTO_REFRESH_UPDATE_SUCCESS` when everything updated,
|
||||
`AUTO_REFRESH_UPDATE_PARTIAL` / `AUTO_REFRESH_UPDATE_FAILED` (error styling,
|
||||
dismissable) when some or all playlists failed, and `AUTO_REFRESH_UPDATE_SKIPPED` when
|
||||
the only unrefreshed playlists lacked a source. A batch that both failed and skipped
|
||||
uses `AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED`, because the plain partial message names
|
||||
only `updated`/`total`/`failed` and would leave the skipped playlists as an unexplained
|
||||
remainder. Every count the user sees must reconcile with `total`, and success is never
|
||||
reported unconditionally — a silently dropped playlist used to look like a successful
|
||||
refresh.
|
||||
`playlist-auto-refresh.e2e.ts` guards this by restarting the app against a killed
|
||||
playlist server.
|
||||
|
||||
## State Management (libs/m3u-state/)
|
||||
|
||||
### State Structure
|
||||
|
||||
@@ -20,6 +20,7 @@ export * from './lib/language.enum';
|
||||
export * from './lib/m3u-favorite-channel.interface';
|
||||
export * from './lib/parsed-playlist.interface';
|
||||
export * from './lib/playback-position.interface';
|
||||
export * from './lib/playlist-auto-update.interface';
|
||||
export * from './lib/playlist-backup.interface';
|
||||
export * from './lib/playlist-meta.type';
|
||||
export * from './lib/playlist-recently-viewed.interface';
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './global-search-result.interface';
|
||||
import { M3uFavoriteChannel } from './m3u-favorite-channel.interface';
|
||||
import { PlaybackPositionData } from './playback-position.interface';
|
||||
import { AutoUpdatePlaylistsResult } from './playlist-auto-update.interface';
|
||||
import {
|
||||
XtreamBackupFavoriteItem,
|
||||
XtreamBackupHiddenCategory,
|
||||
@@ -594,7 +595,7 @@ export interface ElectronBridgeApi {
|
||||
autoUpdatePlaylists: (
|
||||
playlists: Playlist[],
|
||||
options?: ElectronBridgeTrustOptions
|
||||
) => Promise<Playlist[]>;
|
||||
) => Promise<AutoUpdatePlaylistsResult>;
|
||||
fetchEpg: (
|
||||
urls: string[],
|
||||
options?: ElectronBridgeTrustOptions
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Playlist } from './playlist.interface';
|
||||
|
||||
/**
|
||||
* Result of a single playlist inside a startup auto-refresh run.
|
||||
*
|
||||
* - `updated`: the playlist source was fetched/read and parsed successfully
|
||||
* - `failed`: the source could not be reached, read or parsed
|
||||
* - `skipped`: the playlist has neither a URL nor a file path, so there is
|
||||
* nothing to refresh from (a configuration problem, not a failure)
|
||||
*/
|
||||
export type AutoUpdatePlaylistStatus = 'updated' | 'failed' | 'skipped';
|
||||
|
||||
export interface AutoUpdatePlaylistOutcome {
|
||||
playlistId: string;
|
||||
title: string;
|
||||
status: AutoUpdatePlaylistStatus;
|
||||
}
|
||||
|
||||
export interface AutoUpdatePlaylistsResult {
|
||||
/** Only the playlists that were refreshed successfully. */
|
||||
playlists: Playlist[];
|
||||
/** One entry per requested playlist, in request order. */
|
||||
outcomes: AutoUpdatePlaylistOutcome[];
|
||||
}
|
||||
|
||||
export interface AutoUpdatePlaylistsSummary {
|
||||
total: number;
|
||||
updated: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export function summarizeAutoUpdateOutcomes(
|
||||
outcomes: readonly AutoUpdatePlaylistOutcome[]
|
||||
): AutoUpdatePlaylistsSummary {
|
||||
return {
|
||||
total: outcomes.length,
|
||||
updated: outcomes.filter((outcome) => outcome.status === 'updated')
|
||||
.length,
|
||||
failed: outcomes.filter((outcome) => outcome.status === 'failed')
|
||||
.length,
|
||||
skipped: outcomes.filter((outcome) => outcome.status === 'skipped')
|
||||
.length,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user