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
@@ -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[] = [];
|
||||
|
||||
Reference in new issue
Block a user