mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: implement URL probing functionality and enhance time formatting in EPG component
This commit is contained in:
1 parent
826d8d24b1
commit
04e8709d7b
8 files changed
+198
-62
No files matched your search
@@ -44,17 +44,15 @@
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"commands": [
|
||||
"nx run web:serve",
|
||||
"nx run electron-backend:serve-electron"
|
||||
]
|
||||
"command": "nx run electron-backend:serve-electron"
|
||||
}
|
||||
},
|
||||
"serve-electron": {
|
||||
"executor": "nx-electron:execute",
|
||||
"options": {
|
||||
"buildTarget": "electron-backend:build",
|
||||
"args": ["--remote-debugging-port=9222"]
|
||||
"waitUntilTargets": ["web:serve"],
|
||||
"remoteDebuggingPort": 9222
|
||||
}
|
||||
},
|
||||
"package": {
|
||||
|
||||
@@ -254,6 +254,8 @@ contextBridge.exposeInMainWorld('electron', {
|
||||
}) => ipcRenderer.invoke('XTREAM_REQUEST', payload),
|
||||
xtreamCancelSession: (sessionId: string) =>
|
||||
ipcRenderer.invoke('XTREAM_CANCEL_SESSION', sessionId),
|
||||
xtreamProbeUrl: (url: string, method?: 'GET' | 'HEAD') =>
|
||||
ipcRenderer.invoke('XTREAM_PROBE_URL', { url, method }),
|
||||
refreshPlaylist: (payload: PlaylistRefreshPayload) =>
|
||||
ipcRenderer.invoke('PLAYLIST:REFRESH', payload),
|
||||
cancelPlaylistRefresh: (operationId: string) =>
|
||||
|
||||
@@ -10,6 +10,10 @@ export default class App {
|
||||
static application: Electron.App;
|
||||
static BrowserWindow;
|
||||
|
||||
private static shouldOpenDevTools() {
|
||||
return process.env.ELECTRON_OPEN_DEVTOOLS === '1';
|
||||
}
|
||||
|
||||
public static isDevelopmentMode() {
|
||||
// First check ELECTRON_IS_DEV environment variable (used by E2E tests)
|
||||
// This allows E2E tests to run in production mode without packaging
|
||||
@@ -161,7 +165,9 @@ export default class App {
|
||||
// load the index.html of the app.
|
||||
if (App.isDevelopmentMode()) {
|
||||
App.mainWindow.loadURL(`http://localhost:${rendererAppPort}`);
|
||||
App.mainWindow.webContents.openDevTools();
|
||||
if (App.shouldOpenDevTools()) {
|
||||
App.mainWindow.webContents.openDevTools();
|
||||
}
|
||||
} else {
|
||||
App.mainWindow.loadFile(
|
||||
join(__dirname, '..', rendererAppName, 'index.html')
|
||||
|
||||
@@ -44,6 +44,23 @@ function getRecentlyAddedContentTypes(
|
||||
return ['movie', 'series'];
|
||||
}
|
||||
|
||||
function selectContentFields() {
|
||||
return {
|
||||
id: schema.content.id,
|
||||
category_id: schema.content.categoryId,
|
||||
title: schema.content.title,
|
||||
rating: schema.content.rating,
|
||||
added: schema.content.added,
|
||||
poster_url: schema.content.posterUrl,
|
||||
epg_channel_id: schema.content.epgChannelId,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
direct_source: schema.content.directSource,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
};
|
||||
}
|
||||
|
||||
export async function hasContent(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
@@ -72,16 +89,7 @@ export async function getContent(
|
||||
type: 'live' | 'movie' | 'series'
|
||||
) {
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: schema.content.id,
|
||||
category_id: schema.content.categoryId,
|
||||
title: schema.content.title,
|
||||
rating: schema.content.rating,
|
||||
added: schema.content.added,
|
||||
poster_url: schema.content.posterUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
})
|
||||
.select(selectContentFields())
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
@@ -112,15 +120,8 @@ export async function getGlobalRecentlyAdded(
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: schema.content.id,
|
||||
category_id: schema.content.categoryId,
|
||||
title: schema.content.title,
|
||||
rating: schema.content.rating,
|
||||
added: schema.content.added,
|
||||
...selectContentFields(),
|
||||
added_at: schema.content.added,
|
||||
poster_url: schema.content.posterUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
playlist_id: schema.playlists.id,
|
||||
playlist_name: schema.playlists.name,
|
||||
})
|
||||
@@ -150,6 +151,10 @@ type XtreamContentValue = {
|
||||
rating: string;
|
||||
added: string;
|
||||
posterUrl: string;
|
||||
epgChannelId?: string | null;
|
||||
tvArchive?: number | null;
|
||||
tvArchiveDuration?: number | null;
|
||||
directSource?: string | null;
|
||||
xtreamId: number;
|
||||
type: 'live' | 'movie' | 'series';
|
||||
};
|
||||
@@ -161,12 +166,16 @@ type XtreamContentSource = Record<string, unknown> & {
|
||||
last_modified?: string;
|
||||
added?: string;
|
||||
stream_icon?: string;
|
||||
poster?: string;
|
||||
cover?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
series_id?: string | number;
|
||||
stream_id?: string | number;
|
||||
poster?: string;
|
||||
cover?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
epg_channel_id?: string;
|
||||
tv_archive?: string | number;
|
||||
tv_archive_duration?: string | number;
|
||||
direct_source?: string;
|
||||
series_id?: string | number;
|
||||
stream_id?: string | number;
|
||||
};
|
||||
|
||||
function toXtreamContentValue(
|
||||
@@ -205,6 +214,25 @@ function toXtreamContentValue(
|
||||
posterUrl: String(
|
||||
source.stream_icon || source.poster || source.cover || ''
|
||||
),
|
||||
epgChannelId:
|
||||
type === 'live'
|
||||
? String(source.epg_channel_id ?? '').trim() || null
|
||||
: null,
|
||||
tvArchive:
|
||||
type === 'live'
|
||||
? Number.parseInt(String(source.tv_archive ?? '0'), 10) || 0
|
||||
: null,
|
||||
tvArchiveDuration:
|
||||
type === 'live'
|
||||
? Number.parseInt(
|
||||
String(source.tv_archive_duration ?? '0'),
|
||||
10
|
||||
) || 0
|
||||
: null,
|
||||
directSource:
|
||||
type === 'live'
|
||||
? String(source.direct_source ?? '').trim() || null
|
||||
: null,
|
||||
xtreamId:
|
||||
type === 'series'
|
||||
? parseInt(String(source.series_id || '0'))
|
||||
@@ -343,16 +371,7 @@ export async function getContentByXtreamId(
|
||||
playlistId: string
|
||||
) {
|
||||
const result = await db
|
||||
.select({
|
||||
id: schema.content.id,
|
||||
category_id: schema.content.categoryId,
|
||||
title: schema.content.title,
|
||||
rating: schema.content.rating,
|
||||
added: schema.content.added,
|
||||
poster_url: schema.content.posterUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
})
|
||||
.select(selectContentFields())
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
@@ -397,16 +416,7 @@ export async function searchContent(
|
||||
}
|
||||
|
||||
const candidates = await db
|
||||
.select({
|
||||
id: schema.content.id,
|
||||
category_id: schema.content.categoryId,
|
||||
title: schema.content.title,
|
||||
rating: schema.content.rating,
|
||||
added: schema.content.added,
|
||||
poster_url: schema.content.posterUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
})
|
||||
.select(selectContentFields())
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
@@ -449,14 +459,7 @@ export async function globalSearch(
|
||||
|
||||
const candidates = await db
|
||||
.select({
|
||||
id: schema.content.id,
|
||||
category_id: schema.content.categoryId,
|
||||
title: schema.content.title,
|
||||
rating: schema.content.rating,
|
||||
added: schema.content.added,
|
||||
poster_url: schema.content.posterUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
...selectContentFields(),
|
||||
playlist_id: schema.categories.playlistId,
|
||||
playlist_name: schema.playlists.name,
|
||||
})
|
||||
|
||||
@@ -245,3 +245,46 @@ type ActiveXtreamRequest = {
|
||||
};
|
||||
|
||||
const activeXtreamRequests = new Map<string, ActiveXtreamRequest>();
|
||||
|
||||
ipcMain.handle(
|
||||
'XTREAM_PROBE_URL',
|
||||
async (
|
||||
_event,
|
||||
payload: {
|
||||
url: string;
|
||||
method?: 'GET' | 'HEAD';
|
||||
}
|
||||
) => {
|
||||
const config: AxiosRequestConfig = {
|
||||
method: payload.method ?? 'HEAD',
|
||||
url: payload.url,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
},
|
||||
timeout: 10000,
|
||||
maxRedirects: 5,
|
||||
validateStatus: () => true,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios(config);
|
||||
return {
|
||||
status: response.status,
|
||||
url: payload.url,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
return {
|
||||
status: error.response.status,
|
||||
url: payload.url,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 0,
|
||||
url: payload.url,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
+2
-2
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
<div class="epg-timeline">
|
||||
<span class="epg-time">{{
|
||||
formatTime(program.start)
|
||||
formatTime(program.start, program.startTimestamp)
|
||||
}}</span>
|
||||
<div class="epg-progress-track">
|
||||
<div
|
||||
@@ -45,7 +45,7 @@
|
||||
></div>
|
||||
</div>
|
||||
<span class="epg-time">{{
|
||||
formatTime(program.stop)
|
||||
formatTime(program.stop, program.stopTimestamp)
|
||||
}}</span>
|
||||
</div>
|
||||
} @else {
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
import { ChannelListItemComponent } from './channel-list-item.component';
|
||||
|
||||
describe('ChannelListItemComponent', () => {
|
||||
let fixture: ComponentFixture<ChannelListItemComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ChannelListItemComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ChannelListItemComponent);
|
||||
});
|
||||
|
||||
it('formats preview times from timestamp fields when raw strings are offset', () => {
|
||||
const startTimestamp = Math.floor(
|
||||
Date.parse('2026-04-05T05:30:00.000Z') / 1000
|
||||
);
|
||||
const stopTimestamp = Math.floor(
|
||||
Date.parse('2026-04-05T06:00:00.000Z') / 1000
|
||||
);
|
||||
const program: EpgProgram = {
|
||||
start: '2026-04-05 03:00:00',
|
||||
stop: '2026-04-05 03:30:00',
|
||||
channel: 'channel-1',
|
||||
title: 'Current Show',
|
||||
desc: 'Current description',
|
||||
category: null,
|
||||
startTimestamp,
|
||||
stopTimestamp,
|
||||
};
|
||||
|
||||
fixture.componentRef.setInput('name', 'Cartoon Network');
|
||||
fixture.componentRef.setInput('epgProgram', program);
|
||||
fixture.componentRef.setInput('showProgramInfoButton', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const times = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.epg-time'),
|
||||
(element: Element) => element.textContent?.trim() ?? ''
|
||||
);
|
||||
|
||||
expect(times).toEqual([
|
||||
new Date(startTimestamp * 1000).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}),
|
||||
new Date(stopTimestamp * 1000).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
+14
-2
@@ -64,8 +64,20 @@ export class ChannelListItemComponent {
|
||||
/**
|
||||
* Formats time for display (HH:mm)
|
||||
*/
|
||||
formatTime(dateString: string | number): string {
|
||||
const date = new Date(dateString);
|
||||
formatTime(
|
||||
dateString: string | number,
|
||||
timestampSeconds?: number | null
|
||||
): string {
|
||||
const unixTimestamp = Number(timestampSeconds);
|
||||
const date =
|
||||
Number.isFinite(unixTimestamp) && unixTimestamp > 0
|
||||
? new Date(unixTimestamp * 1000)
|
||||
: new Date(dateString);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
|
||||
Reference in new issue
Block a user