// EPG Mapping Manager
class EPGMappingManager {
constructor() {
this.providers = [];
this.currentProvider = null;
this.epgChannels = [];
this.channelData = [];
this.fuzzySet = null;
this.unsavedChanges = false;
this.autoSaveTimeout = null;
// Get API base from current location
this.apiBase = window.location.origin;
this.init();
}
init() {
// Don't load providers on init - wait for tab activation
this.setupEventListeners();
this.createSaveIndicator();
this.initialized = false;
}
async loadProviders() {
// Only load once
if (this.initialized) return;
try {
const response = await fetch(`${this.apiBase}/api/providers`);
if (!response.ok) throw new Error('Failed to load providers');
const data = await response.json();
this.providers = data.providers;
this.renderProviderSelector();
this.initialized = true;
} catch (error) {
this.showError('Failed to load providers', error);
}
}
renderProviderSelector() {
const container = document.getElementById('epg-mapping-container');
if (!container) return;
container.innerHTML = `
`;
this.setupDynamicEventListeners();
}
setupEventListeners() {
// Listen for tab switches - this is the key change
const checkTabSwitch = () => {
const tabs = document.querySelectorAll('.tab');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
if (tab.dataset.tab === 'epg-mapping') {
// Only load providers when EPG tab is first activated
if (!this.initialized) {
this.loadProviders();
}
}
});
});
};
// Check if DOM is already loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', checkTabSwitch);
} else {
checkTabSwitch();
}
}
setupDynamicEventListeners() {
const loadBtn = document.getElementById('epg-load-provider');
if (loadBtn) {
loadBtn.addEventListener('click', () => {
const select = document.getElementById('epg-provider-select');
this.currentProvider = select.value;
if (this.currentProvider) {
this.loadProviderData(this.currentProvider);
}
});
}
const searchInput = document.getElementById('epg-search');
if (searchInput) {
searchInput.addEventListener('input', debounce(() => {
this.filterChannels();
}, 300));
}
['mapped', 'recommended', 'unmapped'].forEach(filter => {
const checkbox = document.getElementById(`filter-${filter}`);
if (checkbox) {
checkbox.addEventListener('change', () => {
this.filterChannels();
});
}
});
const bulkConfirm = document.getElementById('btn-bulk-confirm');
if (bulkConfirm) {
bulkConfirm.addEventListener('click', () => {
this.bulkConfirmRecommended();
});
}
const saveBtn = document.getElementById('btn-save-changes');
if (saveBtn) {
saveBtn.addEventListener('click', () => {
this.saveMappings();
});
}
const exportBtn = document.getElementById('btn-export-mappings');
if (exportBtn) {
exportBtn.addEventListener('click', () => {
this.exportMappings();
});
}
const importBtn = document.getElementById('btn-import-mappings');
if (importBtn) {
importBtn.addEventListener('click', () => {
this.importMappings();
});
}
const closePreview = document.getElementById('close-preview');
if (closePreview) {
closePreview.addEventListener('click', () => {
document.getElementById('epg-preview-modal').style.display = 'none';
});
}
const modal = document.getElementById('epg-preview-modal');
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target.id === 'epg-preview-modal') {
e.target.style.display = 'none';
}
});
}
// Use event delegation for suggestion tags (they're dynamic)
document.addEventListener('click', (e) => {
if (e.target.classList.contains('suggestion-tag')) {
e.preventDefault();
const channelId = e.target.dataset.channel;
const epgId = e.target.dataset.epgId;
const selector = document.querySelector(`.epg-selector[data-channel="${channelId}"]`);
if (selector) {
selector.value = epgId;
this.updateChannelMapping(channelId, epgId);
}
}
});
}
async loadProviderData(providerName) {
try {
// Show loading in the channel list area only
const controlsContainer = document.getElementById('epg-controls');
const channelList = document.getElementById('channel-list');
const bulkActions = document.getElementById('bulk-actions');
if (controlsContainer) controlsContainer.style.display = 'none';
if (channelList) {
channelList.style.display = 'block';
channelList.innerHTML = `
`;
}
if (bulkActions) bulkActions.style.display = 'none';
// First check EPG status
let epgStatusResponse;
try {
epgStatusResponse = await fetch(`${this.apiBase}/api/epg/status`);
if (epgStatusResponse.ok) {
const epgStatus = await epgStatusResponse.json();
if (!epgStatus.configured || epgStatus.epg_url === "https://example.com/epg.xml.gz") {
throw new Error('EPG URL not configured. Please configure a valid EPG URL in the Advanced tab first.');
}
if (!epgStatus.cache_valid) {
console.log('EPG cache not valid, will attempt to download...');
}
}
} catch (statusError) {
console.warn('Could not check EPG status:', statusError);
}
// Load all data in parallel
const [channelsResponse, mappingsResponse, epgResponse] = await Promise.all([
fetch(`${this.apiBase}/api/providers/${providerName}/channels`),
fetch(`${this.apiBase}/api/providers/${providerName}/epg-mapping`),
fetch(`${this.apiBase}/api/epg/xmltv-channels`)
]);
if (!channelsResponse.ok) throw new Error('Failed to load provider channels');
if (!epgResponse.ok) {
const epgError = await epgResponse.json();
throw new Error(epgError.error || 'Failed to load EPG channels. Please check EPG configuration in Advanced tab.');
}
const providerChannels = await channelsResponse.json();
const currentMappings = mappingsResponse.ok ? await mappingsResponse.json() : { mapping: {} };
const epgData = await epgResponse.json();
console.log('Provider channels received:', providerChannels.channels?.length || 0);
console.log('Current mappings response:', currentMappings);
console.log('Mapping object:', currentMappings.mapping);
console.log('Sample mapping keys:', Object.keys(currentMappings.mapping || {}).slice(0, 5));
console.log('Total mapping keys:', Object.keys(currentMappings.mapping || {}).length);
console.log('Sample channel IDs:', providerChannels.channels?.slice(0, 5).map(c => c.Id) || []);
console.log('Sample channel names:', providerChannels.channels?.slice(0, 5).map(c => c.Name) || []);
console.log('EPG channels loaded:', epgData.channels?.length || 0);
console.log('EPG channel map size:', Object.keys(epgData.channel_map || {}).length);
// Store EPG channels with their display names
this.epgChannels = epgData.channels || [];
this.epgChannelMap = epgData.channel_map || {}; // Map of id -> display name
// Create FuzzySet with DISPLAY NAMES, not IDs
const displayNames = Object.values(this.epgChannelMap);
this.fuzzySet = new FuzzySet(displayNames);
this.channelData = providerChannels.channels.map(channel => {
// Use exact property names from StreamingChannel.to_dict()
const channelName = channel.Name;
const channelId = channel.Id;
const channelLogo = channel.LogoUrl || '';
let currentEpgId = null;
const mappingValue = currentMappings.mapping?.[channelId];
console.log(`Channel ${channelId} (${channelName}): mapping value =`, mappingValue);
// Handle different mapping structures:
// 1. String: "epg_id"
// 2. Object: {"epg_id": "...", "name": "..."}
if (typeof mappingValue === 'string') {
currentEpgId = mappingValue;
} else if (mappingValue && typeof mappingValue === 'object') {
currentEpgId = mappingValue.epg_id;
}
const suggestions = this.getSuggestions(channelName);
console.log(` -> EPG ID: ${currentEpgId}, Suggestions:`, suggestions.map(s => `${s.epgId} (${s.score}%)`));
return {
id: channelId,
name: channelName,
logo: channelLogo,
currentEpgId,
suggestions: suggestions.slice(0, 3),
status: this.calculateStatus({ name: channelName }, currentEpgId, suggestions)
};
});
if (controlsContainer) controlsContainer.style.display = 'flex';
if (channelList) channelList.style.display = 'block';
if (bulkActions) bulkActions.style.display = 'flex';
this.updateStats();
this.renderChannelList();
} catch (error) {
this.showError('Failed to load provider data', error);
}
}
getSuggestions(channelName) {
if (!this.fuzzySet || !channelName) return [];
const results = this.fuzzySet.get(channelName);
if (!results) return [];
return results.map(([score, epgDisplayName]) => {
// Find the EPG ID for this display name
let epgId = null;
for (const [id, name] of Object.entries(this.epgChannelMap)) {
if (name === epgDisplayName) {
epgId = id;
break;
}
}
return {
epgId: epgId || epgDisplayName,
displayName: epgDisplayName,
score: Math.round(score * 100)
};
}).filter(s => s.score > 60 && s.epgId).slice(0, 3); // Filter and limit to 3
}
calculateStatus(channel, currentEpgId, suggestions) {
if (currentEpgId) return 'mapped';
const hasGoodSuggestion = suggestions.some(s => s.score > 80);
return hasGoodSuggestion ? 'recommended' : 'unmapped';
}
renderChannelList() {
const container = document.getElementById('channel-list');
if (!container || this.channelData.length === 0) {
if (container) {
container.innerHTML = `
No channels found for this provider.
`;
}
return;
}
const filteredChannels = this.getFilteredChannels();
container.innerHTML = filteredChannels.map(channel => `
${channel.logo ? `

` : ''}
${this.escapeHtml(channel.name)}
ID: ${this.escapeHtml(channel.id)}
${this.getStatusIcon(channel.status)} ${channel.status.charAt(0).toUpperCase() + channel.status.slice(1)}
${channel.status === 'recommended' && channel.suggestions.length > 0 ? `
Suggestions:
${channel.suggestions.map(s => {
const displayName = this.epgChannelMap[s.epgId] || s.epgId;
return `
${this.escapeHtml(displayName)}
`}).join('')}
` : ''}
${channel.status === 'unmapped' && channel.suggestions.length > 0 ? `
Low confidence matches:
${channel.suggestions.slice(0, 2).map(s => {
const displayName = this.epgChannelMap[s.epgId] || s.epgId;
return `
${this.escapeHtml(displayName)} (${s.score}%)
`}).join('')}
` : ''}
${channel.currentEpgId ? `
` : ''}
`).join('');
this.setupChannelEventListeners();
}
setupChannelEventListeners() {
// Only handle static elements here
document.querySelectorAll('.epg-selector').forEach(select => {
select.addEventListener('change', (e) => {
const channelId = e.target.dataset.channel;
const epgId = e.target.value;
this.updateChannelMapping(channelId, epgId);
});
});
document.querySelectorAll('.preview-link').forEach(link => {
link.addEventListener('click', async (e) => {
e.preventDefault();
const epgId = e.target.closest('.preview-link').dataset.epgId;
await this.showEPGPreview(epgId);
});
});
}
updateChannelMapping(channelId, epgId) {
const channel = this.channelData.find(c => c.id === channelId);
if (!channel) return;
channel.currentEpgId = epgId;
// Update status based on whether we have a mapping
if (epgId) {
channel.status = 'mapped';
// Clear suggestions when mapped (they're no longer needed)
channel.suggestions = [];
} else {
// If unmapped, recalculate suggestions
channel.suggestions = this.getSuggestions(channel.name);
channel.status = this.calculateStatus(channel, null, channel.suggestions);
}
const card = document.querySelector(`.channel-card[data-channel-id="${channelId}"]`);
if (card) {
card.className = `channel-card status-${channel.status}`;
const badge = card.querySelector('.status-badge');
if (badge) {
badge.className = `status-badge ${channel.status}`;
badge.innerHTML = `${this.getStatusIcon(channel.status)} ${channel.status.charAt(0).toUpperCase() + channel.status.slice(1)}`;
}
// Update suggestions section (event delegation handles clicks)
const suggestionsContainer = card.querySelector('.epg-suggestions');
if (suggestionsContainer) {
if (channel.status === 'recommended' && channel.suggestions.length > 0) {
suggestionsContainer.innerHTML = `
Suggestions:
${channel.suggestions.map(s => {
const displayName = this.epgChannelMap[s.epgId] || s.epgId;
return `
${this.escapeHtml(displayName)}
`}).join('')}
`;
} else {
suggestionsContainer.innerHTML = '';
}
}
const previewContainer = card.querySelector('.epg-preview');
if (previewContainer) {
if (epgId) {
previewContainer.innerHTML = `
Preview EPG
`;
const newLink = previewContainer.querySelector('.preview-link');
if (newLink) {
newLink.addEventListener('click', async (e) => {
e.preventDefault();
await this.showEPGPreview(epgId);
});
}
} else {
previewContainer.innerHTML = '';
}
}
// Update the selector value
const selector = card.querySelector('.epg-selector');
if (selector) {
selector.value = epgId || '';
}
}
this.updateStats();
this.scheduleAutoSave();
}
getFilteredChannels() {
const showMapped = document.getElementById('filter-mapped')?.checked ?? true;
const showRecommended = document.getElementById('filter-recommended')?.checked ?? true;
const showUnmapped = document.getElementById('filter-unmapped')?.checked ?? true;
const searchTerm = document.getElementById('epg-search')?.value.toLowerCase() || '';
return this.channelData.filter(channel => {
if (channel.status === 'mapped' && !showMapped) return false;
if (channel.status === 'recommended' && !showRecommended) return false;
if (channel.status === 'unmapped' && !showUnmapped) return false;
if (searchTerm) {
return channel.name.toLowerCase().includes(searchTerm) ||
channel.id.toLowerCase().includes(searchTerm) ||
(channel.currentEpgId && channel.currentEpgId.toLowerCase().includes(searchTerm)) ||
channel.suggestions.some(s => s.epgId.toLowerCase().includes(searchTerm));
}
return true;
});
}
filterChannels() {
this.renderChannelList();
}
updateStats() {
const mapped = this.channelData.filter(c => c.status === 'mapped').length;
const recommended = this.channelData.filter(c => c.status === 'recommended').length;
const unmapped = this.channelData.filter(c => c.status === 'unmapped').length;
const mappedEl = document.getElementById('stat-mapped');
const recommendedEl = document.getElementById('stat-recommended');
const unmappedEl = document.getElementById('stat-unmapped');
if (mappedEl) mappedEl.textContent = mapped;
if (recommendedEl) recommendedEl.textContent = recommended;
if (unmappedEl) unmappedEl.textContent = unmapped;
}
getStatusIcon(status) {
switch(status) {
case 'mapped': return '';
case 'recommended': return '';
case 'unmapped': return '';
default: return '';
}
}
async showEPGPreview(epgId) {
try {
// We need to find which channel in the current provider uses this EPG ID
const channel = this.channelData.find(c => c.currentEpgId === epgId);
if (!channel) {
// If no channel is mapped to this EPG ID yet, we can't get EPG data
const modal = document.getElementById('epg-preview-modal');
const body = document.getElementById('epg-preview-body');
body.innerHTML = `
EPG Data for: ${this.escapeHtml(epgId)}
No channel is currently mapped to this EPG ID.
Map a channel first to see EPG data.
`;
modal.style.display = 'flex';
return;
}
// Use the same endpoint as Kodi addon
const response = await fetch(
`${this.apiBase}/api/providers/${this.currentProvider}/channels/${channel.id}/epg`
);
if (!response.ok) throw new Error('No EPG data available');
const epgData = await response.json();
const modal = document.getElementById('epg-preview-modal');
const body = document.getElementById('epg-preview-body');
body.innerHTML = `
EPG Data for: ${this.escapeHtml(channel.name)} → ${this.escapeHtml(epgId)}
${epgData.epg && epgData.epg.length > 0 ? `
${epgData.epg.slice(0, 5).map(program => `
${new Date(program.start * 1000).toLocaleString()} -
${new Date(program.end * 1000).toLocaleTimeString()}
${this.escapeHtml(program.title || 'No title')}
${program.episode_name ? `
${this.escapeHtml(program.episode_name)}` : ''}
${this.escapeHtml(program.description || 'No description available.')}
`).join('')}
` : 'No program data available for this channel.
'}
`;
modal.style.display = 'flex';
} catch (error) {
this.showError('Failed to load EPG preview', error);
}
}
bulkConfirmRecommended() {
const recommendedChannels = this.channelData.filter(c => c.status === 'recommended');
if (recommendedChannels.length === 0) {
alert('No recommended channels to confirm.');
return;
}
if (!confirm(`Confirm ${recommendedChannels.length} recommended mappings?`)) {
return;
}
recommendedChannels.forEach(channel => {
if (channel.suggestions.length > 0) {
const bestSuggestion = channel.suggestions[0];
channel.currentEpgId = bestSuggestion.epgId;
channel.status = 'mapped';
}
});
this.updateStats();
this.renderChannelList();
this.scheduleAutoSave();
alert(`Confirmed ${recommendedChannels.length} mappings. Don't forget to save!`);
}
scheduleAutoSave() {
this.unsavedChanges = true;
this.showSaveIndicator('Unsaved changes');
if (this.autoSaveTimeout) {
clearTimeout(this.autoSaveTimeout);
}
this.autoSaveTimeout = setTimeout(() => {
this.saveMappings(true);
}, 5000);
}
async saveMappings(isAutoSave = false) {
if (!this.currentProvider) return;
if (this.autoSaveTimeout) {
clearTimeout(this.autoSaveTimeout);
this.autoSaveTimeout = null;
}
const mappings = {};
this.channelData.forEach(channel => {
if (channel.currentEpgId) {
// Save in the same structure as loaded
mappings[channel.id] = {
epg_id: channel.currentEpgId,
name: channel.name
};
}
});
try {
this.showSaveIndicator('Saving...');
const response = await fetch(`${this.apiBase}/api/providers/${this.currentProvider}/epg-mapping`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mapping: mappings })
});
if (response.ok) {
this.unsavedChanges = false;
this.showSaveIndicator('Saved successfully!', true);
if (!isAutoSave) {
setTimeout(() => {
this.hideSaveIndicator();
}, 2000);
}
} else {
throw new Error('Save failed');
}
} catch (error) {
this.showSaveIndicator('Save failed!', false);
console.error('Save error:', error);
}
}
exportMappings() {
if (!this.currentProvider) {
alert('Please load a provider first.');
return;
}
const mappings = {};
this.channelData.forEach(channel => {
if (channel.currentEpgId) {
mappings[channel.id] = channel.currentEpgId;
}
});
const data = {
provider: this.currentProvider,
mapping: mappings,
exported_at: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.currentProvider}_epg_mapping_${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
alert('Mappings exported successfully!');
}
importMappings() {
if (!this.currentProvider) {
alert('Please load a provider first.');
return;
}
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.mapping || typeof data.mapping !== 'object') {
throw new Error('Invalid mapping file format');
}
Object.entries(data.mapping).forEach(([channelId, mappingValue]) => {
const channel = this.channelData.find(c => c.id === channelId);
if (channel) {
if (typeof mappingValue === 'string') {
channel.currentEpgId = mappingValue;
} else if (mappingValue && mappingValue.epg_id) {
channel.currentEpgId = mappingValue.epg_id;
}
channel.status = 'mapped';
}
});
this.updateStats();
this.renderChannelList();
this.scheduleAutoSave();
alert(`Imported ${Object.keys(data.mapping).length} mappings successfully!`);
} catch (error) {
alert(`Import failed: ${error.message}`);
}
};
input.click();
}
createSaveIndicator() {
const indicator = document.createElement('div');
indicator.className = 'save-indicator';
indicator.id = 'save-indicator';
indicator.style.display = 'none';
document.body.appendChild(indicator);
}
showSaveIndicator(message, isSuccess = null) {
const indicator = document.getElementById('save-indicator');
if (!indicator) return;
indicator.innerHTML = `
${isSuccess === true ? '' :
isSuccess === false ? '' :
''}
${message}
`;
indicator.style.display = 'flex';
indicator.style.background = isSuccess === true ? 'var(--success-color)' :
isSuccess === false ? 'var(--danger-color)' :
'var(--primary-color)';
}
hideSaveIndicator() {
const indicator = document.getElementById('save-indicator');
if (indicator) {
indicator.style.display = 'none';
}
}
showError(message, error) {
const container = document.getElementById('epg-mapping-container');
if (container) {
container.innerHTML = `
${message}
${this.escapeHtml(error.message || String(error))}
`;
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Initialize when page loads - but don't load data yet
let epgMappingManager;
// Only create the manager instance, don't load data
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
epgMappingManager = new EPGMappingManager();
window.epgMappingManager = epgMappingManager;
});
} else {
epgMappingManager = new EPGMappingManager();
window.epgMappingManager = epgMappingManager;
}