From f852bc74596c0a3165c937767d719b9293314fa8 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:33:07 +0200 Subject: [PATCH] fix(playlists): report failed playlists in the startup auto-refresh toast (#1235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/playlist-auto-refresh.e2e.ts | 113 +++++++++++ .../app/events/playlist-auto-update.spec.ts | 40 +++- .../src/app/events/playlist-auto-update.ts | 35 +++- .../src/app/events/playlist.events.spec.ts | 79 +++++++- .../src/app/events/playlist.events.ts | 8 +- .../auto-update-playlists-feedback.ts | 69 +++++++ .../src/app/services/electron.service.spec.ts | 179 +++++++++++++++++- apps/web/src/app/services/electron.service.ts | 43 ++++- apps/web/src/assets/i18n/ar.json | 4 + apps/web/src/assets/i18n/ary.json | 4 + apps/web/src/assets/i18n/by.json | 4 + apps/web/src/assets/i18n/de.json | 4 + apps/web/src/assets/i18n/el.json | 4 + apps/web/src/assets/i18n/en.json | 4 + apps/web/src/assets/i18n/es.json | 4 + apps/web/src/assets/i18n/fr.json | 4 + apps/web/src/assets/i18n/it.json | 4 + apps/web/src/assets/i18n/ja.json | 4 + apps/web/src/assets/i18n/ko.json | 4 + apps/web/src/assets/i18n/nl.json | 4 + apps/web/src/assets/i18n/pl.json | 4 + apps/web/src/assets/i18n/pt.json | 4 + apps/web/src/assets/i18n/ru.json | 4 + apps/web/src/assets/i18n/tr.json | 4 + apps/web/src/assets/i18n/zh.json | 4 + apps/web/src/assets/i18n/zhtw.json | 4 + docs/architecture/m3u-playlist-module.md | 29 +++ libs/shared/interfaces/src/index.ts | 1 + .../src/lib/electron-api.interface.ts | 3 +- .../src/lib/playlist-auto-update.interface.ts | 45 +++++ 30 files changed, 677 insertions(+), 39 deletions(-) create mode 100644 apps/electron-backend-e2e/src/playlist-auto-refresh.e2e.ts create mode 100644 apps/web/src/app/services/auto-update-playlists-feedback.ts create mode 100644 libs/shared/interfaces/src/lib/playlist-auto-update.interface.ts diff --git a/apps/electron-backend-e2e/src/playlist-auto-refresh.e2e.ts b/apps/electron-backend-e2e/src/playlist-auto-refresh.e2e.ts new file mode 100644 index 000000000..f50fbf8b8 --- /dev/null +++ b/apps/electron-backend-e2e/src/playlist-auto-refresh.e2e.ts @@ -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 { + 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); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/playlist-auto-update.spec.ts b/apps/electron-backend/src/app/events/playlist-auto-update.spec.ts index e3b3af2c8..d3d7c60e8 100644 --- a/apps/electron-backend/src/app/events/playlist-auto-update.spec.ts +++ b/apps/electron-backend/src/app/events/playlist-auto-update.spec.ts @@ -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' ); diff --git a/apps/electron-backend/src/app/events/playlist-auto-update.ts b/apps/electron-backend/src/app/events/playlist-auto-update.ts index c5a2d91e5..6e3975cff 100644 --- a/apps/electron-backend/src/app/events/playlist-auto-update.ts +++ b/apps/electron-backend/src/app/events/playlist-auto-update.ts @@ -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 { +): Promise { 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 => { @@ -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', + })), + }; } diff --git a/apps/electron-backend/src/app/events/playlist.events.spec.ts b/apps/electron-backend/src/app/events/playlist.events.spec.ts index ddbb02659..d25d11f9b 100644 --- a/apps/electron-backend/src/app/events/playlist.events.spec.ts +++ b/apps/electron-backend/src/app/events/playlist.events.spec.ts @@ -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 = { diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index e6b7faf46..d177c7987 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -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; } ); diff --git a/apps/web/src/app/services/auto-update-playlists-feedback.ts b/apps/web/src/app/services/auto-update-playlists-feedback.ts new file mode 100644 index 000000000..9a89dfe5c --- /dev/null +++ b/apps/web/src/app/services/auto-update-playlists-feedback.ts @@ -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; +} diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts index e7000a6e4..8288a78cd 100644 --- a/apps/web/src/app/services/electron.service.spec.ts +++ b/apps/web/src/app/services/electron.service.spec.ts @@ -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 { + 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 } + ); + }); + }); }); diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 7ffdaf6f5..688e80844 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -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; diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 771be4a7c..9314b5c19 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3f867dc10..79be225e7 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 2951c8474..60e369e43 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": { diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 860ad97ce..2510b8cc8 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index b7100e1ec..c56f0ffee 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 05d7dc92f..52f13dc55 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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": { diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 133151e42..a7a3371b8 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 28fe4e577..5f433bab0 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 6c6825239..1ad9e10a3 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index adf2fa981..da94b3a71 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 05a9a4511..93d604731 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 3b67ccf84..0a58db631 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 7d1f64bc4..995eb2d96 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 6afa19dbb..b108eef25 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index ee2abbae2..be97a56f8 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 3d044c8d3..47cd92dd1 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 4e928c56f..0d448ced2 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index c0914a0f0..3664d3cc4 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": { diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 66b4b4283..477c5c355 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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 diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 9d8452872..f1a1660ea 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 827b24eca..fe63da397 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -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; + ) => Promise; fetchEpg: ( urls: string[], options?: ElectronBridgeTrustOptions diff --git a/libs/shared/interfaces/src/lib/playlist-auto-update.interface.ts b/libs/shared/interfaces/src/lib/playlist-auto-update.interface.ts new file mode 100644 index 000000000..12219ddc0 --- /dev/null +++ b/libs/shared/interfaces/src/lib/playlist-auto-update.interface.ts @@ -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, + }; +}