mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
feat(epg): support playlist-scoped sources
Add playlist-scoped EPG source support for M3U playlists.
This commit is contained in:
1 parent
36a7ce9f3d
commit
e801028005
71 files changed
+4876
-373
No files matched your search
@@ -623,6 +623,7 @@ describe('PlaylistRefreshActionService', () => {
|
||||
PlaylistActions.updatePlaylist({
|
||||
playlist: refreshedPlaylist,
|
||||
playlistId: item._id,
|
||||
refreshEpg: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -227,6 +227,7 @@ export class PlaylistRefreshActionService {
|
||||
_id: item._id,
|
||||
},
|
||||
playlistId: item._id,
|
||||
refreshEpg: true,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
+135
@@ -152,6 +152,141 @@
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (canManagePlaylistEpgSources) {
|
||||
<section
|
||||
class="playlist-epg-sources"
|
||||
data-test-id="playlist-epg-sources"
|
||||
>
|
||||
<div class="playlist-epg-sources__header">
|
||||
<mat-icon>event_note</mat-icon>
|
||||
<div>
|
||||
<h3 class="playlist-epg-sources__title">
|
||||
{{ 'SETTINGS.EPG_SOURCES' | translate }}
|
||||
</h3>
|
||||
<p>
|
||||
@if (hiddenDetectedPlaylistEpgSourceCount > 0) {
|
||||
{{
|
||||
'SETTINGS.PLAYLIST_EPG_SOURCES_AUTO_SELECTED'
|
||||
| translate
|
||||
: {
|
||||
enabled:
|
||||
playlistEpgUrls.length,
|
||||
detected:
|
||||
playlistDetectedEpgUrls.length,
|
||||
}
|
||||
}}
|
||||
} @else {
|
||||
{{
|
||||
'SETTINGS.EPG_SOURCES_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@for (epgUrl of playlistEpgUrls; track epgUrl) {
|
||||
<div class="playlist-epg-source-row">
|
||||
<mat-form-field
|
||||
class="w-full"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.EPG_URL_LABEL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput [value]="epgUrl" readonly />
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="!canRefreshPlaylistEpg"
|
||||
[matTooltip]="'SETTINGS.REFRESH_EPG' | translate"
|
||||
(click)="refreshPlaylistEpgSource(epgUrl)"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isGlobalEpgSource(epgUrl)"
|
||||
[matTooltip]="'SETTINGS.ADD_EPG_SOURCE' | translate"
|
||||
(click)="addPlaylistEpgSourceToSettings(epgUrl)"
|
||||
>
|
||||
<mat-icon>playlist_add</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[matTooltip]="
|
||||
'SETTINGS.REMOVE_EPG_SOURCE' | translate
|
||||
"
|
||||
(click)="removePlaylistEpgSource(epgUrl)"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
formArrayName="playlistEpgSourceInputs"
|
||||
class="playlist-epg-source-form"
|
||||
>
|
||||
@for (
|
||||
control of playlistEpgSourceInputs.controls;
|
||||
track $index
|
||||
) {
|
||||
<div class="playlist-epg-source-row">
|
||||
<mat-form-field
|
||||
class="w-full"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.EPG_URL_LABEL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput [formControlName]="$index" />
|
||||
@if (control.invalid && control.touched) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.EPG_URL_ERROR' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[matTooltip]="
|
||||
'SETTINGS.REMOVE_EPG_SOURCE' | translate
|
||||
"
|
||||
(click)="removePlaylistEpgSourceInput($index)"
|
||||
>
|
||||
<mat-icon>remove</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="playlist-epg-source-actions">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="addPlaylistEpgSourceInput()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[attr.aria-label]="
|
||||
('HOME.PLAYLISTS.INFO_DIALOG.SAVE'
|
||||
| translate) +
|
||||
' ' +
|
||||
('SETTINGS.EPG_SOURCES' | translate)
|
||||
"
|
||||
(click)="savePlaylistEpgSources()"
|
||||
>
|
||||
<mat-icon>playlist_add</mat-icon>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.SAVE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@if (isDesktop && (playlist.url || playlist.filePath)) {
|
||||
<mat-checkbox class="w-full" formControlName="autoRefresh">
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
|
||||
|
||||
+301
@@ -4,11 +4,13 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
DatabaseService,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistInfoComponent } from './playlist-info.component';
|
||||
@@ -22,11 +24,21 @@ describe('PlaylistInfoComponent', () => {
|
||||
let databaseService: {
|
||||
updateXtreamPlaylistDetails: jest.Mock;
|
||||
};
|
||||
let epgBridge: {
|
||||
supportsDataManagement: boolean;
|
||||
forceFetchEpg: jest.Mock;
|
||||
clearEpgDataForSource: jest.Mock;
|
||||
};
|
||||
let runtime: {
|
||||
isElectron: boolean;
|
||||
supportsDesktopFileSave: boolean;
|
||||
supportsXtreamSqliteDataSource: boolean;
|
||||
};
|
||||
let settingsStore: {
|
||||
getSettings: jest.Mock;
|
||||
getTrustOptions: jest.Mock;
|
||||
updateSettings: jest.Mock;
|
||||
};
|
||||
let snackBar: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
@@ -55,11 +67,28 @@ describe('PlaylistInfoComponent', () => {
|
||||
databaseService = {
|
||||
updateXtreamPlaylistDetails: jest.fn(),
|
||||
};
|
||||
epgBridge = {
|
||||
supportsDataManagement: true,
|
||||
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
|
||||
clearEpgDataForSource: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true }),
|
||||
};
|
||||
runtime = {
|
||||
isElectron: false,
|
||||
supportsDesktopFileSave: false,
|
||||
supportsXtreamSqliteDataSource: false,
|
||||
};
|
||||
settingsStore = {
|
||||
getSettings: jest.fn(() => ({
|
||||
epgUrl: [],
|
||||
})),
|
||||
getTrustOptions: jest.fn(() => ({
|
||||
trustedPrivateNetworkEpgUrls: [],
|
||||
trustedInsecureTlsHosts: [],
|
||||
})),
|
||||
updateSettings: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
snackBar = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
@@ -85,10 +114,18 @@ describe('PlaylistInfoComponent', () => {
|
||||
provide: DatabaseService,
|
||||
useValue: databaseService,
|
||||
},
|
||||
{
|
||||
provide: EpgRuntimeBridgeService,
|
||||
useValue: epgBridge,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: store,
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: settingsStore,
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: snackBar,
|
||||
@@ -100,7 +137,18 @@ describe('PlaylistInfoComponent', () => {
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
currentLang: 'en',
|
||||
get: jest.fn((key: string) => of(key)),
|
||||
instant: jest.fn((key: string) => key),
|
||||
onDefaultLangChange: of({
|
||||
lang: 'en',
|
||||
translations: {},
|
||||
}),
|
||||
onLangChange: of({ lang: 'en', translations: {} }),
|
||||
onTranslationChange: of({
|
||||
lang: 'en',
|
||||
translations: {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -265,6 +313,259 @@ describe('PlaylistInfoComponent', () => {
|
||||
expect(component.isDesktop).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes detected playlist EPG source URLs for the details UI', () => {
|
||||
TestBed.overrideProvider(MAT_DIALOG_DATA, {
|
||||
useValue: {
|
||||
...playlist,
|
||||
epgUrls: [
|
||||
' https://playlist.example.com/guide.xml ',
|
||||
'',
|
||||
'https://playlist.example.com/guide.xml',
|
||||
'https://playlist.example.com/backup.xml',
|
||||
],
|
||||
},
|
||||
});
|
||||
createComponent();
|
||||
|
||||
expect(component.playlistEpgUrls).toEqual([
|
||||
'https://playlist.example.com/guide.xml',
|
||||
'https://playlist.example.com/backup.xml',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps disabled detected playlist EPG candidates visible in the details UI summary', () => {
|
||||
TestBed.overrideProvider(MAT_DIALOG_DATA, {
|
||||
useValue: {
|
||||
...playlist,
|
||||
epgUrls: ['https://playlist.example.com/ua.xml'],
|
||||
detectedEpgUrls: [
|
||||
'https://playlist.example.com/ua.xml',
|
||||
'https://playlist.example.com/de.xml',
|
||||
'https://playlist.example.com/us.xml',
|
||||
'https://playlist.example.com/fr.xml',
|
||||
],
|
||||
},
|
||||
});
|
||||
createComponent();
|
||||
|
||||
expect(component.playlistDetectedEpgUrls).toEqual([
|
||||
'https://playlist.example.com/ua.xml',
|
||||
'https://playlist.example.com/de.xml',
|
||||
'https://playlist.example.com/us.xml',
|
||||
'https://playlist.example.com/fr.xml',
|
||||
]);
|
||||
expect(component.hiddenDetectedPlaylistEpgSourceCount).toBe(3);
|
||||
});
|
||||
|
||||
it('refreshes a detected playlist EPG source through the runtime bridge', async () => {
|
||||
createComponent();
|
||||
|
||||
await component.refreshPlaylistEpgSource(
|
||||
' https://playlist.example.com/guide.xml '
|
||||
);
|
||||
|
||||
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(
|
||||
'https://playlist.example.com/guide.xml',
|
||||
{
|
||||
trustedPrivateNetworkEpgUrls: [],
|
||||
trustedInsecureTlsHosts: [],
|
||||
}
|
||||
);
|
||||
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'EPG.FETCH_SUCCESS',
|
||||
'CLOSE',
|
||||
{ duration: 3000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a detected playlist EPG source to global settings on request', async () => {
|
||||
settingsStore.getSettings.mockReturnValue({
|
||||
epgUrl: [
|
||||
'https://global.example.com/guide.xml',
|
||||
'https://playlist.example.com/guide.xml',
|
||||
],
|
||||
});
|
||||
createComponent();
|
||||
|
||||
await component.addPlaylistEpgSourceToSettings(
|
||||
'https://new-playlist.example.com/guide.xml'
|
||||
);
|
||||
|
||||
expect(settingsStore.updateSettings).toHaveBeenCalledWith({
|
||||
epgUrl: [
|
||||
'https://global.example.com/guide.xml',
|
||||
'https://playlist.example.com/guide.xml',
|
||||
'https://new-playlist.example.com/guide.xml',
|
||||
],
|
||||
});
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'SETTINGS.ADD_EPG_SOURCE',
|
||||
'CLOSE',
|
||||
{ duration: 3000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('does not duplicate a playlist EPG source that already exists globally', async () => {
|
||||
settingsStore.getSettings.mockReturnValue({
|
||||
epgUrl: ['https://playlist.example.com/guide.xml'],
|
||||
});
|
||||
createComponent();
|
||||
|
||||
await component.addPlaylistEpgSourceToSettings(
|
||||
' https://playlist.example.com/guide.xml '
|
||||
);
|
||||
|
||||
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes a detected playlist EPG source from the enabled list and records it as disabled', async () => {
|
||||
TestBed.overrideProvider(MAT_DIALOG_DATA, {
|
||||
useValue: {
|
||||
...playlist,
|
||||
epgUrls: [
|
||||
'https://playlist.example.com/keep.xml',
|
||||
'https://playlist.example.com/remove.xml',
|
||||
],
|
||||
detectedEpgUrls: [
|
||||
'https://playlist.example.com/keep.xml',
|
||||
'https://playlist.example.com/remove.xml',
|
||||
],
|
||||
manualEpgUrls: ['https://playlist.example.com/manual.xml'],
|
||||
disabledEpgUrls: ['https://playlist.example.com/old.xml'],
|
||||
},
|
||||
});
|
||||
createComponent();
|
||||
|
||||
await component.removePlaylistEpgSource(
|
||||
'https://playlist.example.com/remove.xml'
|
||||
);
|
||||
|
||||
expect(epgBridge.clearEpgDataForSource).toHaveBeenCalledWith(
|
||||
'https://playlist.example.com/remove.xml'
|
||||
);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: expect.objectContaining({
|
||||
_id: 'playlist-1',
|
||||
epgUrls: [
|
||||
'https://playlist.example.com/keep.xml',
|
||||
'https://playlist.example.com/manual.xml',
|
||||
],
|
||||
detectedEpgUrls: [
|
||||
'https://playlist.example.com/keep.xml',
|
||||
'https://playlist.example.com/remove.xml',
|
||||
],
|
||||
manualEpgUrls: ['https://playlist.example.com/manual.xml'],
|
||||
disabledEpgUrls: [
|
||||
'https://playlist.example.com/old.xml',
|
||||
'https://playlist.example.com/remove.xml',
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a playlist EPG source enabled when source data cleanup fails', async () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
epgBridge.clearEpgDataForSource.mockRejectedValueOnce(
|
||||
new Error('Database cleanup failed')
|
||||
);
|
||||
TestBed.overrideProvider(MAT_DIALOG_DATA, {
|
||||
useValue: {
|
||||
...playlist,
|
||||
epgUrls: [
|
||||
'https://playlist.example.com/keep.xml',
|
||||
'https://playlist.example.com/remove.xml',
|
||||
],
|
||||
detectedEpgUrls: [
|
||||
'https://playlist.example.com/keep.xml',
|
||||
'https://playlist.example.com/remove.xml',
|
||||
],
|
||||
manualEpgUrls: [],
|
||||
disabledEpgUrls: [],
|
||||
},
|
||||
});
|
||||
createComponent();
|
||||
|
||||
await component.removePlaylistEpgSource(
|
||||
'https://playlist.example.com/remove.xml'
|
||||
);
|
||||
|
||||
expect(epgBridge.clearEpgDataForSource).toHaveBeenCalledWith(
|
||||
'https://playlist.example.com/remove.xml'
|
||||
);
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'SETTINGS.EPG_DATA_CLEAR_FAILED',
|
||||
'CLOSE',
|
||||
{ duration: 3000 }
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('adds playlist-local EPG sources with URL normalization and deduplication', async () => {
|
||||
TestBed.overrideProvider(MAT_DIALOG_DATA, {
|
||||
useValue: {
|
||||
...playlist,
|
||||
epgUrls: ['https://playlist.example.com/existing.xml'],
|
||||
detectedEpgUrls: ['https://playlist.example.com/existing.xml'],
|
||||
manualEpgUrls: ['https://playlist.example.com/manual.xml'],
|
||||
disabledEpgUrls: ['https://playlist.example.com/new.xml'],
|
||||
},
|
||||
});
|
||||
createComponent();
|
||||
|
||||
component.playlistEpgSourceInputs
|
||||
.at(0)
|
||||
.setValue(' https://playlist.example.com/new.xml ');
|
||||
component.addPlaylistEpgSourceInput();
|
||||
component.playlistEpgSourceInputs
|
||||
.at(1)
|
||||
.setValue('https://playlist.example.com/manual.xml');
|
||||
|
||||
component.savePlaylistEpgSources();
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: expect.objectContaining({
|
||||
_id: 'playlist-1',
|
||||
epgUrls: [
|
||||
'https://playlist.example.com/existing.xml',
|
||||
'https://playlist.example.com/new.xml',
|
||||
'https://playlist.example.com/manual.xml',
|
||||
],
|
||||
manualEpgUrls: [
|
||||
'https://playlist.example.com/manual.xml',
|
||||
'https://playlist.example.com/new.xml',
|
||||
],
|
||||
disabledEpgUrls: [],
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(component.playlistEpgSourceInputs.length).toBe(1);
|
||||
expect(component.playlistEpgSourceInputs.at(0).value).toBe('');
|
||||
});
|
||||
|
||||
it('shows a validation error for invalid playlist-local EPG source URLs', () => {
|
||||
createComponent();
|
||||
fixture.detectChanges();
|
||||
|
||||
component.playlistEpgSourceInputs.at(0).setValue('not a url');
|
||||
component.savePlaylistEpgSources();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'SETTINGS.EPG_URL_ERROR'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to browser download when desktop file saving is unavailable', async () => {
|
||||
const clickSpy = jest
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
|
||||
+293
-1
@@ -4,6 +4,7 @@ import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
FormControl,
|
||||
ReactiveFormsModule,
|
||||
UntypedFormArray,
|
||||
UntypedFormBuilder,
|
||||
UntypedFormGroup,
|
||||
Validators,
|
||||
@@ -21,24 +22,32 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
DatabaseService,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
normalizeXtreamServerUrl,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
normalizeEpgUrls,
|
||||
resolvePlaylistEpgSourceState,
|
||||
} from '@iptvnator/shared/m3u-utils';
|
||||
|
||||
type DesktopFileSaveBridge = Pick<
|
||||
typeof window.electron,
|
||||
'saveFileDialog' | 'writeFile'
|
||||
>;
|
||||
|
||||
const EPG_URL_PATTERN = /^\s*(http|https|file):\/\/[^ "]+\s*$/;
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-info',
|
||||
templateUrl: './playlist-info.component.html',
|
||||
@@ -68,10 +77,61 @@ type DesktopFileSaveBridge = Pick<
|
||||
|
||||
mat-dialog-content p {
|
||||
margin: 0;
|
||||
color: var(--app-muted-color);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.playlist-epg-sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid
|
||||
var(
|
||||
--app-widget-header-border,
|
||||
var(--mat-sys-outline-variant)
|
||||
);
|
||||
border-radius: 8px;
|
||||
background: var(--mat-sys-surface-container-low);
|
||||
}
|
||||
|
||||
.playlist-epg-sources__header {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.playlist-epg-sources__header mat-icon {
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
.playlist-epg-sources__title {
|
||||
margin: 0 0 2px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.playlist-epg-source-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.playlist-epg-source-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.playlist-epg-source-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
providers: [DatePipe],
|
||||
@@ -98,6 +158,8 @@ export class PlaylistInfoComponent {
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private translate = inject(TranslateService);
|
||||
private runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private dialogRef = inject(MatDialogRef<PlaylistInfoComponent>, {
|
||||
optional: true,
|
||||
});
|
||||
@@ -107,6 +169,38 @@ export class PlaylistInfoComponent {
|
||||
return this.runtime.supportsDesktopFileSave;
|
||||
}
|
||||
|
||||
get playlistEpgUrls(): string[] {
|
||||
return this.normalizeEpgUrls(this.playlist.epgUrls);
|
||||
}
|
||||
|
||||
get playlistDetectedEpgUrls(): string[] {
|
||||
const detectedUrls = this.normalizeEpgUrls(
|
||||
this.playlist.detectedEpgUrls
|
||||
);
|
||||
return detectedUrls.length > 0 ? detectedUrls : this.playlistEpgUrls;
|
||||
}
|
||||
|
||||
get hiddenDetectedPlaylistEpgSourceCount(): number {
|
||||
const enabledUrls = new Set(this.playlistEpgUrls);
|
||||
return this.playlistDetectedEpgUrls.filter(
|
||||
(url) => !enabledUrls.has(url)
|
||||
).length;
|
||||
}
|
||||
|
||||
get canRefreshPlaylistEpg(): boolean {
|
||||
return this.epgBridge.supportsDataManagement;
|
||||
}
|
||||
|
||||
get canManagePlaylistEpgSources(): boolean {
|
||||
return !this.playlist.serverUrl && !this.playlist.macAddress;
|
||||
}
|
||||
|
||||
get playlistEpgSourceInputs(): UntypedFormArray {
|
||||
return this.playlistDetails.get(
|
||||
'playlistEpgSourceInputs'
|
||||
) as UntypedFormArray;
|
||||
}
|
||||
|
||||
/** Playlist object */
|
||||
playlist: Playlist & { id: string };
|
||||
|
||||
@@ -159,6 +253,9 @@ export class PlaylistInfoComponent {
|
||||
stalkerDeviceId2: new FormControl(this.playlist.stalkerDeviceId2),
|
||||
stalkerSignature1: new FormControl(this.playlist.stalkerSignature1),
|
||||
stalkerSignature2: new FormControl(this.playlist.stalkerSignature2),
|
||||
playlistEpgSourceInputs: new UntypedFormArray([
|
||||
this.createPlaylistEpgSourceControl(),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -238,6 +335,159 @@ export class PlaylistInfoComponent {
|
||||
}); */
|
||||
}
|
||||
|
||||
async refreshPlaylistEpgSource(url: string): Promise<void> {
|
||||
const normalizedUrl = url.trim();
|
||||
if (!normalizedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.epgBridge.forceFetchEpg(
|
||||
normalizedUrl,
|
||||
this.settingsStore.getTrustOptions()
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
result.success ? 'EPG.FETCH_SUCCESS' : 'EPG.ERROR'
|
||||
),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 3000 }
|
||||
);
|
||||
}
|
||||
|
||||
async addPlaylistEpgSourceToSettings(url: string): Promise<void> {
|
||||
const epgUrl = url.trim();
|
||||
if (!epgUrl || this.isGlobalEpgSource(epgUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSettings = this.settingsStore.getSettings();
|
||||
await this.settingsStore.updateSettings({
|
||||
epgUrl: this.normalizeEpgUrls([
|
||||
...(currentSettings.epgUrl ?? []),
|
||||
epgUrl,
|
||||
]),
|
||||
});
|
||||
|
||||
this.snackBar.open(
|
||||
this.translate.instant('SETTINGS.ADD_EPG_SOURCE'),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 3000 }
|
||||
);
|
||||
}
|
||||
|
||||
isGlobalEpgSource(url: string): boolean {
|
||||
const normalizedUrl = url.trim();
|
||||
if (!normalizedUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.normalizeEpgUrls(
|
||||
this.settingsStore.getSettings().epgUrl
|
||||
).includes(normalizedUrl);
|
||||
}
|
||||
|
||||
async removePlaylistEpgSource(url: string): Promise<void> {
|
||||
const epgUrl = url.trim();
|
||||
if (!epgUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.epgBridge.supportsDataManagement) {
|
||||
try {
|
||||
const result =
|
||||
await this.epgBridge.clearEpgDataForSource(epgUrl);
|
||||
if (result && result.success === false) {
|
||||
throw new Error('Clear EPG source returned false');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to clear playlist EPG source data:',
|
||||
error
|
||||
);
|
||||
this.snackBar.open(
|
||||
this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED'),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 3000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const detectedEpgUrls = this.getRawDetectedPlaylistEpgUrls();
|
||||
const disabledEpgUrls = this.normalizeEpgUrls(
|
||||
this.playlist.disabledEpgUrls
|
||||
);
|
||||
const nextDisabledEpgUrls = detectedEpgUrls.includes(epgUrl)
|
||||
? this.normalizeEpgUrls([...disabledEpgUrls, epgUrl])
|
||||
: disabledEpgUrls.filter((disabledUrl) => disabledUrl !== epgUrl);
|
||||
|
||||
const state = resolvePlaylistEpgSourceState({
|
||||
detectedEpgUrls,
|
||||
enabledEpgUrls: this.playlistEpgUrls.filter(
|
||||
(enabledUrl) => enabledUrl !== epgUrl
|
||||
),
|
||||
manualEpgUrls: this.normalizeEpgUrls(
|
||||
this.playlist.manualEpgUrls
|
||||
).filter((manualUrl) => manualUrl !== epgUrl),
|
||||
disabledEpgUrls: nextDisabledEpgUrls,
|
||||
});
|
||||
|
||||
this.applyPlaylistEpgSourceState(state);
|
||||
}
|
||||
|
||||
addPlaylistEpgSourceInput(): void {
|
||||
this.playlistEpgSourceInputs.push(
|
||||
this.createPlaylistEpgSourceControl()
|
||||
);
|
||||
}
|
||||
|
||||
removePlaylistEpgSourceInput(index: number): void {
|
||||
if (this.playlistEpgSourceInputs.length <= 1) {
|
||||
this.playlistEpgSourceInputs.at(0).reset('');
|
||||
return;
|
||||
}
|
||||
|
||||
this.playlistEpgSourceInputs.removeAt(index);
|
||||
}
|
||||
|
||||
savePlaylistEpgSources(): void {
|
||||
if (this.playlistEpgSourceInputs.invalid) {
|
||||
this.playlistEpgSourceInputs.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const addedUrls = this.normalizeEpgUrls(
|
||||
this.playlistEpgSourceInputs.value as string[]
|
||||
);
|
||||
if (addedUrls.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addedUrlSet = new Set(addedUrls);
|
||||
const state = resolvePlaylistEpgSourceState({
|
||||
detectedEpgUrls: this.getRawDetectedPlaylistEpgUrls(),
|
||||
enabledEpgUrls: this.normalizeEpgUrls([
|
||||
...this.playlistEpgUrls,
|
||||
...addedUrls,
|
||||
]),
|
||||
manualEpgUrls: this.normalizeEpgUrls([
|
||||
...(this.playlist.manualEpgUrls ?? []),
|
||||
...addedUrls,
|
||||
]),
|
||||
disabledEpgUrls: this.normalizeEpgUrls(
|
||||
this.playlist.disabledEpgUrls
|
||||
).filter((url) => !addedUrlSet.has(url)),
|
||||
});
|
||||
|
||||
this.applyPlaylistEpgSourceState(state);
|
||||
this.resetPlaylistEpgSourceInputs();
|
||||
}
|
||||
|
||||
async exportPlaylist() {
|
||||
const playlistAsString = await firstValueFrom(
|
||||
this.playlistsService.getRawPlaylistById(this.playlist._id)
|
||||
@@ -322,4 +572,46 @@ export class PlaylistInfoComponent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeEpgUrls(urls?: string[] | null): string[] {
|
||||
return normalizeEpgUrls(urls ?? []);
|
||||
}
|
||||
|
||||
private createPlaylistEpgSourceControl(value = ''): FormControl<string> {
|
||||
return new FormControl(value, {
|
||||
nonNullable: true,
|
||||
validators: [Validators.pattern(EPG_URL_PATTERN)],
|
||||
});
|
||||
}
|
||||
|
||||
private getRawDetectedPlaylistEpgUrls(): string[] {
|
||||
return this.normalizeEpgUrls(this.playlist.detectedEpgUrls);
|
||||
}
|
||||
|
||||
private applyPlaylistEpgSourceState(
|
||||
state: ReturnType<typeof resolvePlaylistEpgSourceState>
|
||||
): void {
|
||||
const playlistMeta = {
|
||||
_id: this.playlist._id,
|
||||
epgUrls: state.epgUrls,
|
||||
detectedEpgUrls: state.detectedEpgUrls,
|
||||
manualEpgUrls: state.manualEpgUrls,
|
||||
disabledEpgUrls: state.disabledEpgUrls,
|
||||
} as PlaylistMeta;
|
||||
|
||||
this.playlist = {
|
||||
...this.playlist,
|
||||
...playlistMeta,
|
||||
};
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({ playlist: playlistMeta })
|
||||
);
|
||||
}
|
||||
|
||||
private resetPlaylistEpgSourceInputs(): void {
|
||||
this.playlistEpgSourceInputs.clear();
|
||||
this.playlistEpgSourceInputs.push(
|
||||
this.createPlaylistEpgSourceControl()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -462,6 +462,7 @@ export class RecentPlaylistsComponent {
|
||||
_id: item._id,
|
||||
},
|
||||
playlistId: item._id,
|
||||
refreshEpg: true,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user