Merge remote-tracking branch 'origin/master' into claude/vod-multi-source-49a8ba

This commit is contained in:
4gray committed 2026-07-29 00:10:35 +02:00
commit 26b820cc29
60 files changed
+2772 -160

No files matched your search

+8 -22
View File
@@ -21,13 +21,13 @@ import {
import {
AUTO_UPDATE_PLAYLISTS,
Language,
OPEN_FILE,
Settings,
STORE_KEY,
Theme,
createDevLogger,
} from '@iptvnator/shared/interfaces';
import { SettingsService } from './services/settings.service';
import { PlaylistOpenRequestService } from './services/playlist-open-request.service';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
const debugAppComponent = createDevLogger('AppComponent');
@@ -59,6 +59,7 @@ export class AppComponent implements OnInit {
private translate = inject(TranslateService);
private settingsService = inject(SettingsService);
private settingsStore = inject(SettingsStore);
private playlistOpenRequests = inject(PlaylistOpenRequestService);
private runtime = inject(RuntimeCapabilitiesService);
private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS);
@@ -72,27 +73,12 @@ export class AppComponent implements OnInit {
document.body.classList.add('frameless-platform');
}
const electronProcess = this.dataService.remote?.process;
if (
this.dataService.isElectron &&
electronProcess &&
(electronProcess.platform === 'linux' ||
electronProcess.platform === 'win32') &&
electronProcess.argv.length > 2
) {
const filePath = electronProcess.argv.find(
(filepath: string) =>
filepath.endsWith('.m3u') || filepath.endsWith('.m3u8')
);
if (filePath) {
const filePathsArray = filePath.split('/');
const fileName = filePathsArray[filePathsArray.length - 1];
this.dataService.sendIpcEvent(OPEN_FILE, {
filePath,
fileName,
});
}
}
// Playlist files the OS asked us to open (command line argument, file
// association, macOS `open-file`) are resolved in the main process and
// queued there until the renderer subscribes. Start listening as early
// as possible so a first-launch file is not delayed behind app init.
this.playlistOpenRequests.start();
effect(() => {
const size = this.settingsStore.coverSize?.() ?? 'medium';
document.documentElement.dataset.coverSize = size;
@@ -0,0 +1,218 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util';
import type { ElectronBridgePlaylistOpenRequest } from '@iptvnator/shared/interfaces';
import { PlaylistOpenRequestService } from './playlist-open-request.service';
type BridgeMock = {
acknowledgePlaylistOpenRequest: jest.Mock;
announcePlaylistOpenListener: jest.Mock;
onPlaylistOpenRequest: jest.Mock;
};
describe('PlaylistOpenRequestService', () => {
let service: PlaylistOpenRequestService;
let importService: { importFromPath: jest.Mock };
let snackBar: { open: jest.Mock };
let bridge: BridgeMock;
let pushRequest: (request: ElectronBridgePlaylistOpenRequest) => void;
let unsubscribe: jest.Mock;
let originalElectron: typeof window.electron | undefined;
const request = (name: string): ElectronBridgePlaylistOpenRequest => ({
fileName: `${name}.m3u`,
filePath: `/tmp/${name}.m3u`,
requestId: name,
});
/** Lets every queued promise callback settle. */
const settle = () =>
new Promise((resolvePromise) => setTimeout(resolvePromise, 0));
beforeEach(() => {
originalElectron = window.electron;
pushRequest = () => undefined;
unsubscribe = jest.fn();
importService = {
importFromPath: jest.fn().mockResolvedValue({
ok: true,
title: 'Playlist',
}),
};
snackBar = { open: jest.fn() };
bridge = {
acknowledgePlaylistOpenRequest: jest
.fn()
.mockResolvedValue(undefined),
announcePlaylistOpenListener: jest
.fn()
.mockResolvedValue(undefined),
onPlaylistOpenRequest: jest.fn((callback) => {
pushRequest = callback;
return unsubscribe;
}),
};
window.electron = bridge as unknown as typeof window.electron;
TestBed.configureTestingModule({
providers: [
PlaylistOpenRequestService,
{ provide: PlaylistFileImportService, useValue: importService },
{ provide: MatSnackBar, useValue: snackBar },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
],
});
service = TestBed.inject(PlaylistOpenRequestService);
});
afterEach(() => {
window.electron = originalElectron as typeof window.electron;
jest.restoreAllMocks();
});
// The announcement makes the main process flush its queue at once, so a
// listener attached afterwards would miss every startup file.
it('subscribes before announcing itself', () => {
service.start();
expect(
bridge.onPlaylistOpenRequest.mock.invocationCallOrder[0]
).toBeLessThan(
bridge.announcePlaylistOpenListener.mock.invocationCallOrder[0]
);
});
it('imports a playlist the main process pushes', async () => {
service.start();
pushRequest(request('startup'));
await settle();
expect(importService.importFromPath).toHaveBeenCalledWith(
'/tmp/startup.m3u',
'startup.m3u'
);
});
// `send()` returns before the listener runs, so the main process holds the
// request until this confirms a live renderer got it.
it('acknowledges a request as soon as it arrives', async () => {
importService.importFromPath.mockReturnValue(new Promise(() => undefined));
service.start();
pushRequest(request('startup'));
expect(bridge.acknowledgePlaylistOpenRequest).toHaveBeenCalledWith(
'startup'
);
});
it('keeps importing when acknowledging fails', async () => {
bridge.acknowledgePlaylistOpenRequest.mockRejectedValue(
new Error('gone')
);
service.start();
pushRequest(request('startup'));
await settle();
expect(importService.importFromPath).toHaveBeenCalledWith(
'/tmp/startup.m3u',
'startup.m3u'
);
});
it('reports a failed import to the user', async () => {
importService.importFromPath.mockResolvedValue({
ok: false,
reason: 'read-error',
});
service.start();
pushRequest(request('broken'));
await settle();
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.FILE_UPLOAD.OPEN_FAILED',
'CLOSE',
expect.objectContaining({ panelClass: ['error-snackbar'] })
);
});
// The main-process queue flushes back to back, so a burst of pushes must
// not fan out into parallel file reads landing in arbitrary order — the
// last playlist imported is the one the app navigates to.
it('never runs two imports at the same time', async () => {
let inFlight = 0;
let maxInFlight = 0;
const resolvers: (() => void)[] = [];
importService.importFromPath.mockImplementation(() => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
return new Promise((resolvePromise) => {
resolvers.push(() => {
inFlight -= 1;
resolvePromise({ ok: true, title: 'Playlist' });
});
});
});
service.start();
pushRequest(request('one'));
pushRequest(request('two'));
pushRequest(request('three'));
for (let step = 0; step < 12; step += 1) {
resolvers.shift()?.();
await settle();
}
expect(maxInFlight).toBe(1);
expect(
importService.importFromPath.mock.calls.map(([path]) => path)
).toEqual(['/tmp/one.m3u', '/tmp/two.m3u', '/tmp/three.m3u']);
});
it('keeps importing after one file fails', async () => {
importService.importFromPath
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce({ ok: true, title: 'Playlist' });
service.start();
pushRequest(request('broken'));
pushRequest(request('healthy'));
await settle();
expect(importService.importFromPath).toHaveBeenCalledWith(
'/tmp/healthy.m3u',
'healthy.m3u'
);
});
it('starts only once', () => {
service.start();
service.start();
expect(bridge.announcePlaylistOpenListener).toHaveBeenCalledTimes(1);
});
it('does nothing without the Electron bridge', () => {
window.electron = undefined as unknown as typeof window.electron;
expect(() => service.start()).not.toThrow();
expect(importService.importFromPath).not.toHaveBeenCalled();
});
it('releases the listener on stop', () => {
service.start();
service.stop();
expect(unsubscribe).toHaveBeenCalled();
});
});
@@ -0,0 +1,122 @@
import { Injectable, inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util';
import type { ElectronBridgePlaylistOpenRequest } from '@iptvnator/shared/interfaces';
type PlaylistOpenRequestBridge = {
acknowledgePlaylistOpenRequest?: (requestId: string) => Promise<void>;
announcePlaylistOpenListener?: () => Promise<void>;
onPlaylistOpenRequest?: (
callback: (request: ElectronBridgePlaylistOpenRequest) => void
) => () => void;
};
/**
* Imports playlist files the operating system handed to the app: a command
* line argument, a file association double-click, or macOS' `open-file`.
*
* The main process queues those requests until this service announces itself,
* so the listener has to be attached *before* the announcement — otherwise the
* push that follows it has nowhere to land. Electron only; in the PWA the
* bridge is absent and `start()` is a no-op.
*/
@Injectable({ providedIn: 'root' })
export class PlaylistOpenRequestService {
private readonly importService = inject(PlaylistFileImportService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private started = false;
private unsubscribe: (() => void) | undefined;
/**
* Imports run strictly one after another, in arrival order. The main
* process flushes its queue back to back, so without this a burst — two
* playlists picked in Finder at once — would fan out into parallel
* file reads and land in a nondeterministic order, with an arbitrary one
* of them ending up the active playlist. `addPlaylist$` serialises the
* persistence side separately.
*/
private importChain: Promise<void> = Promise.resolve();
start(): void {
if (this.started) {
return;
}
const bridge = this.getBridge();
if (!bridge?.announcePlaylistOpenListener) {
return;
}
this.started = true;
// Subscribe first: announcing makes the main process flush its queue
// immediately, and every request — queued or live — arrives this way.
this.unsubscribe = bridge.onPlaylistOpenRequest?.((request) => {
// Acknowledge on receipt rather than after the import: the main
// process only needs to know the request reached a live renderer,
// and holding the ack back until a slow import finished would
// replay it if the window were closed meanwhile.
void bridge
.acknowledgePlaylistOpenRequest?.(request?.requestId)
.catch(() => undefined);
this.chain(() => this.open(request));
});
void bridge.announcePlaylistOpenListener().catch((error) => {
console.error(
'Failed to announce the playlist open listener:',
error
);
});
}
/** Appends a step to the import chain, never breaking it on failure. */
private chain(step: () => Promise<void>): void {
this.importChain = this.importChain.then(step).catch((error) => {
console.error('Failed to open a playlist file:', error);
});
}
stop(): void {
this.unsubscribe?.();
this.unsubscribe = undefined;
this.started = false;
}
private async open(
request: ElectronBridgePlaylistOpenRequest
): Promise<void> {
if (!request?.filePath) {
return;
}
const result = await this.importService.importFromPath(
request.filePath,
request.fileName
);
if (result.ok) {
return;
}
this.snackBar.open(
this.translate.instant('HOME.FILE_UPLOAD.OPEN_FAILED', {
filename: request.fileName || request.filePath,
}),
this.translate.instant('CLOSE'),
{ duration: 7000, panelClass: ['error-snackbar'] }
);
}
private getBridge(): PlaylistOpenRequestBridge | undefined {
if (typeof window === 'undefined') {
return undefined;
}
return (window as Window & { electron?: PlaylistOpenRequestBridge })
.electron;
}
}
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "أو",
"CHOOSE_PLAYLIST": "اختر قائمة تشغيل",
"BROWSE": "تصفح الملفات",
"REJECTED": "تم رفض الملف، تنسيق غير مدعوم {{filename}}."
"REJECTED": "تم رفض الملف، تنسيق غير مدعوم {{filename}}.",
"OPEN_FAILED": "تعذر فتح \"{{filename}}\"."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "رابط قائمة التشغيل (M3U/M3U8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "أو",
"CHOOSE_PLAYLIST": "اختار قائمة تشغيل",
"BROWSE": "تصفح الملفات",
"REJECTED": "الملف ترفض، صيغة الملف غير مدعومة {{filename}}."
"REJECTED": "الملف ترفض، صيغة الملف غير مدعومة {{filename}}.",
"OPEN_FAILED": "ما تقدرش تحل \"{{filename}}\"."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "رابط قائمة التشغيل (m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "або",
"CHOOSE_PLAYLIST": "выберыце плэйліст",
"BROWSE": "Агляд файлаў",
"REJECTED": "На жаль, гэты фармат файлаў не падтрымліваецца {{filename}}."
"REJECTED": "На жаль, гэты фармат файлаў не падтрымліваецца {{filename}}.",
"OPEN_FAILED": "Не атрымалася адкрыць \"{{filename}}\"."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Спасылка на плэйліст (*.m3u або *.m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "oder",
"CHOOSE_PLAYLIST": "Playlist auswählen",
"BROWSE": "Dateien durchsuchen",
"REJECTED": "Datei wurde abgelehnt, nicht unterstütztes Dateiformat {{filename}}."
"REJECTED": "Datei wurde abgelehnt, nicht unterstütztes Dateiformat {{filename}}.",
"OPEN_FAILED": "\"{{filename}}\" konnte nicht geöffnet werden."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL der Playlist",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "or",
"CHOOSE_PLAYLIST": "επιλέξτε μια λίστα αναπαραγωγής",
"BROWSE": "Περιήγηση αρχείων",
"REJECTED": "Το αρχείο απορρίφθηκε, η μορφή αρχείου δεν υποστηρίζεται {{filename}}."
"REJECTED": "Το αρχείο απορρίφθηκε, η μορφή αρχείου δεν υποστηρίζεται {{filename}}.",
"OPEN_FAILED": "Δεν ήταν δυνατό το άνοιγμα του \"{{filename}}\"."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Διεύθηνση URL λίστας αναπαραγωγής (m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "or",
"CHOOSE_PLAYLIST": "click to browse",
"BROWSE": "Browse files",
"REJECTED": "File was rejected, unsupported file format {{filename}}."
"REJECTED": "File was rejected, unsupported file format {{filename}}.",
"OPEN_FAILED": "Could not open \"{{filename}}\"."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Playlist URL (m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "o",
"CHOOSE_PLAYLIST": "Escoga una lista de reproducción",
"BROWSE": "Explorar archivos",
"REJECTED": "El archivo fue rechazado, formato de archivo no compatible {{filename}}."
"REJECTED": "El archivo fue rechazado, formato de archivo no compatible {{filename}}.",
"OPEN_FAILED": "No se pudo abrir \"{{filename}}\"."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Lista de reproducción (m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "ou",
"CHOOSE_PLAYLIST": "Choisir une liste de lecture",
"BROWSE": "Parcourir les fichiers",
"REJECTED": "Le fichier a été rejeté, format de fichier non pris en charge {{filename}}."
"REJECTED": "Le fichier a été rejeté, format de fichier non pris en charge {{filename}}.",
"OPEN_FAILED": "Impossible d’ouvrir \"{{filename}}\"."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL de la liste de lecture (m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "vagy",
"CHOOSE_PLAYLIST": "kattintson a tallózáshoz",
"BROWSE": "Fájlok tallózása",
"REJECTED": "Elutasított fájl: {{filename}} – nem támogatott fájlformátum."
"REJECTED": "Elutasított fájl: {{filename}} – nem támogatott fájlformátum.",
"OPEN_FAILED": "A(z) \"{{filename}}\" nem nyitható meg."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Lejátszási lista URL-címe (m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "o",
"CHOOSE_PLAYLIST": "scegli una playlist",
"BROWSE": "Sfoglia file",
"REJECTED": "Il file è stato rifiutato, formato di file non supportato {{filename}}."
"REJECTED": "Il file è stato rifiutato, formato di file non supportato {{filename}}.",
"OPEN_FAILED": "Impossibile aprire \"{{filename}}\"."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL playlist (m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "または",
"CHOOSE_PLAYLIST": "プレイリストを選択",
"BROWSE": "ファイルを参照",
"REJECTED": "ファイルが拒否されました。サポートされていないファイル形式です:{{filename}}"
"REJECTED": "ファイルが拒否されました。サポートされていないファイル形式です:{{filename}}",
"OPEN_FAILED": "\"{{filename}}\" を開けませんでした。"
},
"URL_UPLOAD": {
"PLAYLIST_URL": "プレイリストURL (m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "또는",
"CHOOSE_PLAYLIST": "재생 목록을 선택",
"BROWSE": "파일 찾아보기",
"REJECTED": "파일이 거부되었습니다. 지원되지 않는 파일 형식 {{filename}}."
"REJECTED": "파일이 거부되었습니다. 지원되지 않는 파일 형식 {{filename}}.",
"OPEN_FAILED": "\"{{filename}}\" 파일을 열 수 없습니다."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "재생 목록 URL (m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "of",
"CHOOSE_PLAYLIST": "kies een afspeellijst",
"BROWSE": "Bestanden bladeren",
"REJECTED": "Bestand geweigerd, niet-ondersteund bestandsformaat: {{filename}}."
"REJECTED": "Bestand geweigerd, niet-ondersteund bestandsformaat: {{filename}}.",
"OPEN_FAILED": "Kan \"{{filename}}\" niet openen."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Afspeellijst-URL (m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "lub",
"CHOOSE_PLAYLIST": "wybierz listę odtwarzania",
"BROWSE": "Przeglądaj pliki",
"REJECTED": "Plik został odrzucony, nieobsługiwany format pliku {{filename}}."
"REJECTED": "Plik został odrzucony, nieobsługiwany format pliku {{filename}}.",
"OPEN_FAILED": "Nie udało się otworzyć \"{{filename}}\"."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL listy odtwarzania (m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "ou",
"CHOOSE_PLAYLIST": "escolha uma playlist",
"BROWSE": "Procurar arquivos",
"REJECTED": "O arquivo foi rejeitado, formato de arquivo não suportado {{filename}}."
"REJECTED": "O arquivo foi rejeitado, formato de arquivo não suportado {{filename}}.",
"OPEN_FAILED": "Não foi possível abrir \"{{filename}}\"."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL da playlist (m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "или",
"CHOOSE_PLAYLIST": "выберите плейлист",
"BROWSE": "Выбрать файлы",
"REJECTED": "К сожалению, данный формат файлов не поддерживается {{filename}}."
"REJECTED": "К сожалению, данный формат файлов не поддерживается {{filename}}.",
"OPEN_FAILED": "Не удалось открыть \"{{filename}}\"."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Ссылка на плейлист (*.m3u или *.m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "veya",
"CHOOSE_PLAYLIST": "oynatma listesi seçin",
"BROWSE": "Dosyalara göz at",
"REJECTED": "Dosya reddedildi, desteklenmeyen dosya formatı {{filename}}."
"REJECTED": "Dosya reddedildi, desteklenmeyen dosya formatı {{filename}}.",
"OPEN_FAILED": "\"{{filename}}\" açılamadı."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Oynatma listesi URL'si (m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "或",
"CHOOSE_PLAYLIST": "选择一个播放列表",
"BROWSE": "浏览文件",
"REJECTED": "文件已被拒绝,不支持的文件格式 {{filename}}。"
"REJECTED": "文件已被拒绝,不支持的文件格式 {{filename}}。",
"OPEN_FAILED": "无法打开 \"{{filename}}\"。"
},
"URL_UPLOAD": {
"PLAYLIST_URL": "播放一个播放列表(m3u, m3u8)",
+2 -1
View File
@@ -151,7 +151,8 @@
"OR": "或者",
"CHOOSE_PLAYLIST": "選擇播放清單",
"BROWSE": "瀏覽檔案",
"REJECTED": "檔案被拒絕,不支援的檔案格式 {{filename}}。"
"REJECTED": "檔案被拒絕,不支援的檔案格式 {{filename}}。",
"OPEN_FAILED": "無法開啟 \"{{filename}}\"。"
},
"URL_UPLOAD": {
"PLAYLIST_URL": "播放清單網址(m3u,m3u8)",