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

@@ -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[] = [];