Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/electron-backend/src/app/api/main.preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,14 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }),
searchEpgPrograms: (searchTerm: string, limit?: number) =>
ipcRenderer.invoke('EPG_DB_SEARCH_PROGRAMS', searchTerm, limit),
getEpgMapping: (channelKey: string) =>
ipcRenderer.invoke('EPG_MAPPING_GET', { channelKey }),
setEpgMapping: (channelKey: string, epgChannelId: string, playlistId?: string) =>
ipcRenderer.invoke('EPG_MAPPING_SET', { channelKey, epgChannelId, playlistId }),
deleteEpgMapping: (channelKey: string) =>
ipcRenderer.invoke('EPG_MAPPING_DELETE', { channelKey }),
searchEpgChannels: (searchTerm: string, limit?: number) =>
ipcRenderer.invoke('EPG_CHANNEL_SEARCH', { searchTerm, limit }),
setMpvPlayerPath: (mpvPlayerPath: string) =>
ipcRenderer.invoke('SET_MPV_PLAYER_PATH', mpvPlayerPath),
setVlcPlayerPath: (vlcPlayerPath: string) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { and, eq, inArray, or, sql } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';

/**
* Get a single EPG channel mapping by lookup key.
*/
export async function getEpgMapping(
db: AppDatabase,
channelKey: string
): Promise<{ id: number; channelKey: string; epgChannelId: string; playlistId: string | null } | null> {
const rows = await db
.select({
id: schema.epgChannelMappings.id,
channelKey: schema.epgChannelMappings.channelKey,
epgChannelId: schema.epgChannelMappings.epgChannelId,
playlistId: schema.epgChannelMappings.playlistId,
})
.from(schema.epgChannelMappings)
.where(eq(schema.epgChannelMappings.channelKey, channelKey))
.limit(1);

return rows[0] ?? null;
}

/**
* Batch lookup of EPG channel mappings for multiple keys.
* Returns a Map of channelKey → epgChannelId (only for keys that have mappings).
*/
export async function getEpgMappingsBatch(
db: AppDatabase,
channelKeys: string[]
): Promise<Map<string, string>> {
if (channelKeys.length === 0) {
return new Map();
}

const rows = await db
.select({
channelKey: schema.epgChannelMappings.channelKey,
epgChannelId: schema.epgChannelMappings.epgChannelId,
})
.from(schema.epgChannelMappings)
.where(inArray(schema.epgChannelMappings.channelKey, channelKeys));

const mapping = new Map<string, string>();
for (const row of rows) {
mapping.set(row.channelKey, row.epgChannelId);
}
return mapping;
}

/**
* Create or update an EPG channel mapping (upsert).
*/
export async function setEpgMapping(
db: AppDatabase,
channelKey: string,
epgChannelId: string,
playlistId?: string
): Promise<{ success: boolean }> {
await db
.insert(schema.epgChannelMappings)
.values({
channelKey,
epgChannelId,
playlistId: playlistId ?? null,
})
.onConflictDoUpdate({
target: schema.epgChannelMappings.channelKey,
set: {
epgChannelId,
playlistId: playlistId ?? null,
updatedAt: sql`(datetime('now'))`,
},
});

return { success: true };
}

/**
* Remove an EPG channel mapping.
*/
export async function deleteEpgMapping(
db: AppDatabase,
channelKey: string
): Promise<{ success: boolean }> {
await db
.delete(schema.epgChannelMappings)
.where(eq(schema.epgChannelMappings.channelKey, channelKey));

return { success: true };
}

/**
* Search EPG channels by display name for the mapping dialog.
*/
export async function searchEpgChannels(
db: AppDatabase,
searchTerm: string,
limit = 50
): Promise<Array<{ id: string; displayName: string; iconUrl: string | null }>> {
// Escape LIKE wildcards so user input like "HBO%" or "_BC" is literal.
// Drizzle's like() doesn't support ESCAPE, so we use raw SQL.
const escaped = searchTerm.trim().replace(/[%_\\]/g, '\\$&');
const pattern = `%${escaped}%`;

return db
.select({
id: schema.epgChannels.id,
displayName: schema.epgChannels.displayName,
iconUrl: schema.epgChannels.iconUrl,
})
.from(schema.epgChannels)
.where(
or(
sql`${schema.epgChannels.displayName} LIKE ${pattern} ESCAPE '\\'`,
sql`${schema.epgChannels.id} LIKE ${pattern} ESCAPE '\\'`
)
)
.orderBy(schema.epgChannels.displayName)
.limit(limit);
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export async function getFavorites(db: AppDatabase, playlistId: string) {
poster_url: schema.content.posterUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
tv_archive: schema.content.tvArchive,
tv_archive_duration: schema.content.tvArchiveDuration,
epg_channel_id: schema.content.epgChannelId,
added_at: schema.favorites.addedAt,
position: schema.favorites.position,
})
Expand Down Expand Up @@ -117,6 +120,9 @@ function selectGlobalFavoriteRows(
: {}),
xtream_id: schema.content.xtreamId,
type: schema.content.type,
tv_archive: schema.content.tvArchive,
tv_archive_duration: schema.content.tvArchiveDuration,
epg_channel_id: schema.content.epgChannelId,
playlist_id: schema.playlists.id,
playlist_name: schema.playlists.name,
added_at: schema.favorites.addedAt,
Expand Down
1 change: 1 addition & 0 deletions apps/electron-backend/src/app/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
recentlyViewed,
favorites,
epgChannels,
epgChannelMappings,
epgPrograms,
playbackPositions,
downloads,
Expand Down
99 changes: 92 additions & 7 deletions apps/electron-backend/src/app/events/epg-query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,21 @@ export class EpgQueryService {
): Promise<EpgProgram[]> {
try {
const db = await getDatabase();
const trimmedChannelId = channelId.trim();
let trimmedChannelId = channelId.trim();
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);

if (!trimmedChannelId) {
return [];
}

// Check for manual EPG mapping — if a user has mapped this
// channel key to a different EPG channel ID, use the mapped
// ID for all subsequent lookups.
const mapped = await this.getMapping(db, trimmedChannelId);
if (mapped) {
trimmedChannelId = mapped;
}

let results = await this.selectChannelPrograms(
db,
trimmedChannelId,
Expand Down Expand Up @@ -495,22 +503,40 @@ export class EpgQueryService {
return matchedCandidateIds;
}

private readonly EPG_WINDOW_START_DAYS = 14;
private readonly EPG_WINDOW_END_DAYS = 2;

private epgTimeWindow(): { start: string; end: string } {
const now = new Date();
const start = new Date(
now.getTime() - this.EPG_WINDOW_START_DAYS * 24 * 60 * 60 * 1000
).toISOString();
const end = new Date(
now.getTime() + this.EPG_WINDOW_END_DAYS * 24 * 60 * 60 * 1000
).toISOString();
return { start, end };
}

private async selectChannelPrograms(
db: EpgDatabase,
channelId: string,
sourceUrls: string[]
): Promise<EpgProgramRow[]> {
const { start, end } = this.epgTimeWindow();
return db
.select()
.from(schema.epgPrograms)
.where(
this.withProgramSourceScope(
eq(schema.epgPrograms.channelId, channelId),
and(
eq(schema.epgPrograms.channelId, channelId),
gte(schema.epgPrograms.stop, start),
lte(schema.epgPrograms.start, end)
) as SQL,
sourceUrls
)
)
.orderBy(schema.epgPrograms.start)
.limit(500);
.orderBy(schema.epgPrograms.start);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

private async selectLegacyChannelPrograms(
Expand All @@ -522,18 +548,22 @@ export class EpgQueryService {
return [];
}

const { start, end } = this.epgTimeWindow();
return db
.select()
.from(schema.epgPrograms)
.where(
this.withProgramSourceScope(
eq(schema.epgPrograms.channelId, channelId),
and(
eq(schema.epgPrograms.channelId, channelId),
gte(schema.epgPrograms.stop, start),
lte(schema.epgPrograms.start, end)
) as SQL,
sourceUrls,
{ legacyOnly: true }
)
)
.orderBy(schema.epgPrograms.start)
.limit(500);
.orderBy(schema.epgPrograms.start);
}

private async selectCurrentProgramsForChannelIds(
Expand Down Expand Up @@ -823,6 +853,61 @@ export class EpgQueryService {
!Number.isNaN(new Date(program.stop).getTime())
);
}

private async getMapping(
db: EpgDatabase,
channelKey: string
): Promise<string | null> {
try {
// Direct lookup by channel key.
const rows = await db
.select({
epgChannelId: schema.epgChannelMappings.epgChannelId,
})
.from(schema.epgChannelMappings)
.where(
eq(schema.epgChannelMappings.channelKey, channelKey)
)
.limit(1);
if (rows.length > 0) {
return rows[0].epgChannelId;
}

// Fallback: if the key looks like an Xtream epg_channel_id, also
// check if a mapping was saved using the xtream_id instead.
const contentRows = await db
.select({
xtreamId: schema.content.xtreamId,
})
.from(schema.content)
.where(
eq(schema.content.epgChannelId, channelKey)
)
.limit(1);
if (contentRows.length > 0) {
const xtreamId = String(contentRows[0].xtreamId);
const mapped = await db
.select({
epgChannelId: schema.epgChannelMappings.epgChannelId,
})
.from(schema.epgChannelMappings)
.where(
eq(
schema.epgChannelMappings.channelKey,
xtreamId
)
)
.limit(1);
if (mapped.length > 0) {
return mapped[0].epgChannelId;
}
}

return null;
} catch {
return null;
}
}
}

export const epgQueryService = new EpgQueryService();
Loading