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:
4grayandClaude Opus 5 authored and GitHub committed 2026-07-25 11:33:07 +02:00
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 }
);
});
});
});
+33 -10
View File
@@ -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;
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+29
View File
@@ -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
+1
View File
@@ -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,
};
}