mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix: preserve file paths for local playlist refresh
Refs https://github.com/4gray/iptvnator/issues/891 Entire-Checkpoint: f957cd9849e0
This commit is contained in:
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: {
|
||||
|
||||
@@ -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[] }[]
|
||||
|
||||
Vendored
+2
-1
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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[] = [];
|
||||
|
||||
+13
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user