// 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 = `
Mapped: 0 Recommended: 0 Unmapped: 0

EPG Preview

`; 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 = `

Loading EPG data...

`; } 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; }