fix: preserve file paths for local playlist refresh

Refs https://github.com/4gray/iptvnator/issues/891

Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray committed 2026-05-04 10:06:39 +02:00
1 parent 568c03fe0e
commit 2efdfea0c1
12 files changed
+457 -5

No files matched your search

@@ -289,6 +289,60 @@ export async function importM3uPlaylistFromNativeDialog(
await dialog.waitFor({ state: 'detached' });
}
export async function dropM3uPlaylistOntoWorkspace(
page: Page,
filePath: string
): Promise<void> {
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: {
+41 -1
View File
@@ -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', [
{
@@ -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[] }[]
+2 -1
View File
@@ -55,7 +55,8 @@ declare global {
filePath: string,
title: string
) => Promise<Playlist>;
openPlaylistFromFile: () => Promise<Playlist>;
openPlaylistFromFile: () => Promise<Playlist | null>;
getPathForFile: (file: File) => string;
saveFileDialog: (
defaultPath: string,
filters?: { name: string; extensions: string[] }[]
@@ -23,6 +23,7 @@
type="button"
class="file-drop"
[class.file-drop--dragging]="isDragging()"
[disabled]="isImporting()"
appDragDropFileUpload
(fileDropped)="onFilesDropped($event)"
(dragStateChanged)="onDragStateChange($event)"
@@ -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<FileUploadComponent>;
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();
});
});
@@ -24,7 +24,12 @@ export class FileUploadComponent {
readonly isDragging = signal(false);
readonly isImporting = signal(false);
openPicker(input: HTMLInputElement): void {
async openPicker(input: HTMLInputElement): Promise<void> {
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<void> {
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');
}
}
}
@@ -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...');
@@ -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();
});
});
@@ -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<Playlist | null>;
};
};
@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<NativePlaylistFileImportResult> {
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<PlaylistFileImportResult> {
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<Playlist | null>)
| 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;
}
}
@@ -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[] = [];
@@ -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,