From 2efdfea0c177d5f8e667ddc5aaa966e111fc8c9b Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 4 May 2026 10:06:39 +0200 Subject: [PATCH] fix: preserve file paths for local playlist refresh Refs https://github.com/4gray/iptvnator/issues/891 Entire-Checkpoint: f957cd9849e0 --- .../src/electron-test-fixtures.ts | 54 ++++++++ apps/electron-backend-e2e/src/sources.e2e.ts | 42 ++++++- .../src/app/api/main.preload.ts | 3 +- apps/web/src/typings.d.ts | 3 +- .../file-upload/file-upload.component.html | 1 + .../file-upload/file-upload.component.spec.ts | 71 +++++++++++ .../lib/file-upload/file-upload.component.ts | 24 +++- .../playlist-item.component.spec.ts | 19 +++ .../lib/playlist-file-import.service.spec.ts | 119 ++++++++++++++++++ .../src/lib/playlist-file-import.service.ts | 83 +++++++++++- .../playlist-refresh-action.service.spec.ts | 30 +++++ ...orkspace-dashboard-rails.component.spec.ts | 13 ++ 12 files changed, 457 insertions(+), 5 deletions(-) create mode 100644 libs/playlist/import/feature/src/lib/file-upload/file-upload.component.spec.ts create mode 100644 libs/playlist/shared/util/src/lib/playlist-file-import.service.spec.ts diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index a566dced7..d37a9354b 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -289,6 +289,60 @@ export async function importM3uPlaylistFromNativeDialog( await dialog.waitFor({ state: 'detached' }); } +export async function dropM3uPlaylistOntoWorkspace( + page: Page, + filePath: string +): Promise { + await expect(page.locator('.workspace-shell')).toBeVisible({ + timeout: 20000, + }); + + const inputHandle = await page.evaluateHandle(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.style.position = 'fixed'; + input.style.left = '-9999px'; + document.body.append(input); + return input; + }); + const inputElement = inputHandle.asElement(); + if (!inputElement) { + await inputHandle.dispose(); + throw new Error('Could not create temporary file input.'); + } + + await inputElement.setInputFiles(filePath); + + await page.evaluate((element) => { + const input = element as HTMLInputElement; + const file = input.files?.[0]; + if (!file) { + throw new Error('Temporary file input did not receive a file.'); + } + + const target = document.querySelector('.workspace-shell'); + if (!target) { + throw new Error('Workspace drop target was not found.'); + } + + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + + for (const type of ['dragenter', 'dragover', 'drop']) { + target.dispatchEvent( + new DragEvent(type, { + bubbles: true, + cancelable: true, + dataTransfer, + }) + ); + } + + input.remove(); + }, inputElement); + await inputHandle.dispose(); +} + export async function addXtreamPortal( page: Page, options: { diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index 2deeb8b40..7c198e900 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -4,11 +4,13 @@ import { closeElectronApp, createMutableTextServer, deleteSource, + dropM3uPlaylistOntoWorkspace, dragSourceBefore, expect, expectPlaylistUpdatedToast, expectSourceDialogValues, getVisibleSourceTitles, + goToDashboard, importM3uPlaylistFromNativeDialog, importM3uPlaylistFromUrl, launchElectronApp, @@ -425,7 +427,37 @@ https://streams.example.test/original-url.m3u8 const app = await launchElectronApp(dataDir); try { - await importM3uPlaylistFromNativeDialog(app, localFilePath); + await dropM3uPlaylistOntoWorkspace( + app.mainWindow, + localFilePath + ); + await waitForM3uCatalog(app.mainWindow); + await expect( + app.mainWindow.locator( + 'app-playlist-switcher .trigger-refresh-button' + ) + ).toBeVisible(); + + await goToDashboard(app.mainWindow); + const sourceCard = app.mainWindow + .getByTestId('dashboard-recent-sources-rail-card') + .filter({ + hasText: refreshLocalSourceDisplayName, + }) + .first(); + await expect(sourceCard).toBeVisible({ timeout: 20000 }); + await sourceCard.hover(); + await sourceCard + .getByTestId('dashboard-recent-sources-rail-card-actions') + .click(); + await expect( + app.mainWindow.getByRole('menuitem', { + name: 'Refresh playlist', + exact: true, + }) + ).toBeVisible(); + await app.mainWindow.keyboard.press('Escape'); + await importM3uPlaylistFromUrl( app.mainWindow, urlServer.resourceUrl @@ -434,6 +466,14 @@ https://streams.example.test/original-url.m3u8 name: 'Refresh Xtream Source', }); await openSources(app.mainWindow); + await expect( + sourceRowByTitle( + app.mainWindow, + refreshLocalSourceDisplayName + ) + .first() + .locator('.refresh-btn') + ).toBeVisible(); writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [ { diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 72d636d72..a00f8f6a7 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from 'electron'; +import { contextBridge, ipcRenderer, webUtils } from 'electron'; import type { EmbeddedMpvBounds, EmbeddedMpvSession, @@ -291,6 +291,7 @@ const electronApi = { updatePlaylistFromFilePath: (filePath: string, title: string) => ipcRenderer.invoke('update-playlist-from-file-path', filePath, title), openPlaylistFromFile: () => ipcRenderer.invoke('open-playlist-from-file'), + getPathForFile: (file: File) => webUtils.getPathForFile(file), saveFileDialog: ( defaultPath: string, filters?: { name: string; extensions: string[] }[] diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 8200afd21..8ae50e31f 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -55,7 +55,8 @@ declare global { filePath: string, title: string ) => Promise; - openPlaylistFromFile: () => Promise; + openPlaylistFromFile: () => Promise; + getPathForFile: (file: File) => string; saveFileDialog: ( defaultPath: string, filters?: { name: string; extensions: string[] }[] diff --git a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.html b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.html index 91b5f406a..666e2d8a6 100644 --- a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.html +++ b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.html @@ -23,6 +23,7 @@ type="button" class="file-drop" [class.file-drop--dragging]="isDragging()" + [disabled]="isImporting()" appDragDropFileUpload (fileDropped)="onFilesDropped($event)" (dragStateChanged)="onDragStateChange($event)" diff --git a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.spec.ts b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.spec.ts new file mode 100644 index 000000000..cca167dcd --- /dev/null +++ b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.spec.ts @@ -0,0 +1,71 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util'; +import { FileUploadComponent } from './file-upload.component'; + +describe('FileUploadComponent', () => { + let fixture: ComponentFixture; + let component: FileUploadComponent; + let importService: { + canImportFromNativeDialog: jest.Mock; + importFile: jest.Mock; + importFromNativeDialog: jest.Mock; + isSupportedFile: jest.Mock; + }; + + beforeEach(async () => { + importService = { + canImportFromNativeDialog: jest.fn().mockReturnValue(false), + importFile: jest.fn(), + importFromNativeDialog: jest.fn(), + isSupportedFile: jest.fn().mockReturnValue(true), + }; + + await TestBed.configureTestingModule({ + imports: [FileUploadComponent, TranslateModule.forRoot()], + providers: [ + { + provide: PlaylistFileImportService, + useValue: importService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(FileUploadComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('uses the native Electron picker when it is available', async () => { + const imported = jest.fn(); + const hiddenInput = { + value: 'previous', + click: jest.fn(), + } as unknown as HTMLInputElement; + importService.canImportFromNativeDialog.mockReturnValue(true); + importService.importFromNativeDialog.mockResolvedValue({ + ok: true, + title: 'Local Source', + }); + component.imported.subscribe(imported); + + await component.openPicker(hiddenInput); + + expect(hiddenInput.click).not.toHaveBeenCalled(); + expect(importService.importFromNativeDialog).toHaveBeenCalledTimes(1); + expect(imported).toHaveBeenCalledWith({ title: 'Local Source' }); + }); + + it('falls back to the hidden file input outside Electron', async () => { + const hiddenInput = { + value: 'previous', + click: jest.fn(), + } as unknown as HTMLInputElement; + + await component.openPicker(hiddenInput); + + expect(hiddenInput.value).toBe(''); + expect(hiddenInput.click).toHaveBeenCalledTimes(1); + expect(importService.importFromNativeDialog).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts index bcb887d81..e51714762 100644 --- a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts @@ -24,7 +24,12 @@ export class FileUploadComponent { readonly isDragging = signal(false); readonly isImporting = signal(false); - openPicker(input: HTMLInputElement): void { + async openPicker(input: HTMLInputElement): Promise { + if (this.importService.canImportFromNativeDialog()) { + await this.importFromNativeDialog(); + return; + } + input.value = ''; input.click(); } @@ -91,4 +96,21 @@ export class FileUploadComponent { } this.selectedFile.set(file); } + + private async importFromNativeDialog(): Promise { + if (this.isImporting()) return; + + this.isImporting.set(true); + const result = await this.importService.importFromNativeDialog(); + this.isImporting.set(false); + + if (result.ok === true) { + this.imported.emit({ title: result.title }); + return; + } + + if (result.reason !== 'cancelled') { + this.fileRejected.emit('selected playlist'); + } + } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts index e00aa9321..ad99e6351 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts @@ -75,6 +75,25 @@ describe('PlaylistItemComponent', () => { expect(emitSpy).not.toHaveBeenCalled(); }); + it('renders a refresh action for file-backed M3U playlists', () => { + fixture.destroy(); + fixture = TestBed.createComponent(PlaylistItemComponent); + component = fixture.componentInstance; + component.item = { + title: 'Local Source', + _id: 'local-source', + count: 10, + importDate: Date.now().toString(), + autoRefresh: false, + filePath: '/tmp/local-source.m3u', + }; + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + + expect(nativeElement.querySelector('.refresh-btn')).not.toBeNull(); + }); + it('renders cancel and progress UI for long-running playlist actions', () => { fixture.componentRef.setInput('isDeleting', true); fixture.componentRef.setInput('busyMessage', 'Removing cached content...'); diff --git a/libs/playlist/shared/util/src/lib/playlist-file-import.service.spec.ts b/libs/playlist/shared/util/src/lib/playlist-file-import.service.spec.ts new file mode 100644 index 000000000..98d01f8c5 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/playlist-file-import.service.spec.ts @@ -0,0 +1,119 @@ +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { PlaylistActions } from 'm3u-state'; +import { Playlist } from 'shared-interfaces'; +import { PlaylistFileImportService } from './playlist-file-import.service'; + +describe('PlaylistFileImportService', () => { + let service: PlaylistFileImportService; + let store: { dispatch: jest.Mock }; + let originalElectron: typeof window.electron | undefined; + + beforeEach(() => { + originalElectron = window.electron; + store = { + dispatch: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + PlaylistFileImportService, + { + provide: Store, + useValue: store, + }, + ], + }); + + service = TestBed.inject(PlaylistFileImportService); + }); + + afterEach(() => { + window.electron = originalElectron as typeof window.electron; + jest.restoreAllMocks(); + }); + + it('passes the Electron file path through when importing a picked file', async () => { + const file = { + name: 'local-source.m3u', + path: '/tmp/local-source.m3u', + text: jest.fn().mockResolvedValue('#EXTM3U'), + } as unknown as File; + + const result = await service.importFile(file); + + expect(result).toEqual({ ok: true, title: 'local-source' }); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.parsePlaylist({ + uploadType: 'FILE', + playlist: '#EXTM3U', + title: 'local-source', + path: '/tmp/local-source.m3u', + }) + ); + }); + + it('resolves the Electron file path for dropped files without a path property', async () => { + const file = { + name: 'dropped-source.m3u', + text: jest.fn().mockResolvedValue('#EXTM3U'), + } as unknown as File; + window.electron = { + getPathForFile: jest.fn().mockReturnValue('/tmp/dropped-source.m3u'), + } as unknown as typeof window.electron; + + const result = await service.importFile(file); + + expect(result).toEqual({ ok: true, title: 'dropped-source' }); + expect(window.electron.getPathForFile).toHaveBeenCalledWith(file); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.parsePlaylist({ + uploadType: 'FILE', + playlist: '#EXTM3U', + title: 'dropped-source', + path: '/tmp/dropped-source.m3u', + }) + ); + }); + + it('adds native Electron file-dialog playlists with their stored file path', async () => { + const playlist = { + _id: 'playlist-1', + title: 'Native Local Source', + filename: 'Native Local Source', + count: 1, + importDate: '2026-05-04T12:00:00.000Z', + lastUsage: '2026-05-04T12:00:00.000Z', + autoRefresh: false, + filePath: '/tmp/native-local-source.m3u', + favorites: [], + playlist: { + items: [], + }, + } as Playlist; + window.electron = { + openPlaylistFromFile: jest.fn().mockResolvedValue(playlist), + } as unknown as typeof window.electron; + + const result = await service.importFromNativeDialog(); + + expect(result).toEqual({ + ok: true, + title: 'Native Local Source', + }); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.addPlaylist({ playlist }) + ); + }); + + it('does not dispatch when the native file dialog is cancelled', async () => { + window.electron = { + openPlaylistFromFile: jest.fn().mockResolvedValue(null), + } as unknown as typeof window.electron; + + const result = await service.importFromNativeDialog(); + + expect(result).toEqual({ ok: false, reason: 'cancelled' }); + expect(store.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts b/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts index 46e07ebff..227a8bc7c 100644 --- a/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts +++ b/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts @@ -1,6 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { Store } from '@ngrx/store'; import { PlaylistActions } from 'm3u-state'; +import { Playlist } from 'shared-interfaces'; const M3U_EXTENSIONS = ['.m3u', '.m3u8']; @@ -8,15 +9,54 @@ export type PlaylistFileImportResult = | { ok: true; title: string } | { ok: false; reason: 'unsupported' | 'empty' | 'read-error' }; +export type NativePlaylistFileImportResult = + | PlaylistFileImportResult + | { ok: false; reason: 'cancelled' }; + +type NativePlaylistFileImportWindow = Window & { + electron?: { + getPathForFile?: (file: File) => string; + openPlaylistFromFile?: () => Promise; + }; +}; + @Injectable({ providedIn: 'root' }) export class PlaylistFileImportService { private readonly store = inject(Store); + canImportFromNativeDialog(): boolean { + return Boolean(this.getNativeFileDialog()); + } + isSupportedFile(file: File): boolean { const lower = file.name.toLowerCase(); return M3U_EXTENSIONS.some((ext) => lower.endsWith(ext)); } + async importFromNativeDialog(): Promise { + const openPlaylistFromFile = this.getNativeFileDialog(); + if (!openPlaylistFromFile) { + return { ok: false, reason: 'read-error' }; + } + + try { + const playlist = await openPlaylistFromFile(); + if (!playlist) { + return { ok: false, reason: 'cancelled' }; + } + + this.store.dispatch(PlaylistActions.addPlaylist({ playlist })); + + return { + ok: true, + title: + playlist.title || playlist.filename || 'Untitled playlist', + }; + } catch { + return { ok: false, reason: 'read-error' }; + } + } + async importFile(file: File): Promise { if (!this.isSupportedFile(file)) { return { ok: false, reason: 'unsupported' }; @@ -39,7 +79,7 @@ export class PlaylistFileImportService { uploadType: 'FILE', playlist, title, - path: (file as File & { path?: string }).path, + path: this.getFilePath(file), }) ); @@ -53,4 +93,45 @@ export class PlaylistFileImportService { } return trimmed.replace(/\.(m3u8?|pls|txt)$/i, '') || trimmed; } + + private getFilePath(file: File): string | undefined { + const directPath = (file as File & { path?: string }).path?.trim(); + if (directPath) { + return directPath; + } + + const getPathForFile = this.getNativeFilePathResolver(); + if (!getPathForFile) { + return undefined; + } + + try { + const resolvedPath = getPathForFile(file).trim(); + return resolvedPath || undefined; + } catch { + return undefined; + } + } + + private getNativeFileDialog(): + | (() => Promise) + | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + return (window as NativePlaylistFileImportWindow).electron + ?.openPlaylistFromFile; + } + + private getNativeFilePathResolver(): + | ((file: File) => string) + | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + return (window as NativePlaylistFileImportWindow).electron + ?.getPathForFile; + } } diff --git a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts index 6bb77cebf..00811da9a 100644 --- a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts @@ -183,6 +183,36 @@ describe('PlaylistRefreshActionService', () => { localStorage.clear(); }); + it('treats file-backed M3U playlists as refreshable in Electron', () => { + window.electron = { platform: 'darwin' } as typeof window.electron; + + expect( + service.canRefresh( + createPlaylistMeta({ + serverUrl: undefined, + username: undefined, + password: undefined, + filePath: '/tmp/local-source.m3u', + }) + ) + ).toBe(true); + }); + + it('does not expose filesystem refresh outside Electron', () => { + window.electron = undefined as unknown as typeof window.electron; + + expect( + service.canRefresh( + createPlaylistMeta({ + serverUrl: undefined, + username: undefined, + password: undefined, + filePath: '/tmp/local-source.m3u', + }) + ) + ).toBe(false); + }); + it('stores Xtream restore data before updating playlist meta and navigating', async () => { const item = createPlaylistMeta(); const executionOrder: string[] = []; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index a679dce5c..24eefd192 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -30,6 +30,19 @@ describe('buildDashboardSourceActions', () => { ]); }); + it('exposes refresh, info, and remove for file-backed M3U sources', () => { + const playlist = { + ...basePlaylist, + filePath: '/tmp/local-playlist.m3u', + } as PlaylistMeta; + + expect(actionIds(playlist, true)).toEqual([ + 'refresh', + 'playlist-info', + 'remove', + ]); + }); + it('exposes refresh, info, account, and remove for refreshable Xtream sources', () => { const playlist = { ...basePlaylist,