Add web config

This commit is contained in:
Nirvana
2025-12-13 23:24:46 +01:00
parent 4b2fe9eabc
commit 1513094853
7 changed files with 1604 additions and 9 deletions
+24 -1
View File
@@ -37,6 +37,9 @@
<div class="tab" data-tab="advanced">
<i class="fas fa-cogs"></i> Advanced
</div>
<div class="tab" data-tab="epg-mapping">
<i class="fas fa-satellite-dish"></i> EPG Mapping
</div>
</div>
<!-- Credentials Tab -->
@@ -128,6 +131,27 @@
</div>
</div>
</div>
<div id="tab-epg-mapping" class="tab-content">
<div class="card-body">
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
<div>
<strong>EPG Channel Mapping</strong>
<p>Map provider channels to EPG IDs for electronic program guide data. Fuzzy matching suggests possible matches based on channel names.</p>
</div>
</div>
<!-- EPG Mapping Container (loaded by epg_mapping.js) -->
<div id="epg-mapping-container">
<div class="empty-state" id="epg-loading">
<i class="fas fa-satellite-dish fa-spin"></i>
<p>Loading EPG Mapping...</p>
</div>
</div>
</div>
</div>
</main>
<footer style="text-align: center; margin-top: 30px; color: white; opacity: 0.8;">
@@ -148,6 +172,5 @@
</div>
</div>
<script src="config.js"></script>
</body>
</html>
+5
View File
@@ -44,6 +44,11 @@ function switchTab(tabId) {
if (tabId === 'proxy') {
loadProxyForms();
}
// Load EPG mappings if switching to that tab
if (tabId === 'epg-mapping' && window.epgMappingManager) {
window.epgMappingManager.loadProviders();
}
}
// Load providers from API
+440
View File
@@ -0,0 +1,440 @@
/* EPG Mapping Specific Styles */
.epg-mapping-container {
padding: 15px 0;
}
/* Header Section */
.mapping-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
flex-wrap: wrap;
gap: 15px;
}
.provider-selector {
display: flex;
gap: 10px;
align-items: center;
}
.provider-selector select {
min-width: 250px;
padding: 10px 15px;
border: 2px solid var(--primary-color);
border-radius: var(--border-radius);
background: white;
font-size: 1rem;
}
.header-stats {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.stat {
padding: 8px 16px;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 6px;
}
.stat.mapped {
background: rgba(76, 201, 240, 0.2);
color: var(--success-color);
border: 1px solid rgba(76, 201, 240, 0.3);
}
.stat.recommended {
background: rgba(255, 193, 7, 0.2);
color: #ffc107;
border: 1px solid rgba(255, 193, 7, 0.3);
}
.stat.unmapped {
background: rgba(255, 87, 34, 0.2);
color: #ff5722;
border: 1px solid rgba(255, 87, 34, 0.3);
}
/* Controls Section */
.mapping-controls {
background: var(--light-color);
padding: 20px;
border-radius: var(--border-radius);
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
gap: 20px;
align-items: center;
}
.search-box {
flex: 1;
min-width: 300px;
position: relative;
}
.search-box input {
width: 100%;
padding: 12px 15px 12px 45px;
border: 2px solid #dee2e6;
border-radius: var(--border-radius);
font-size: 1rem;
}
.search-box i {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: var(--gray-color);
}
.filters {
display: flex;
gap: 20px;
align-items: center;
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.filter-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
/* Channel List */
.channel-mapping-list {
max-height: 600px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: var(--border-radius);
margin-bottom: 25px;
}
.channel-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #eee;
transition: var(--transition);
background: white;
}
.channel-card:hover {
background: #f8f9fa;
}
.channel-card:last-child {
border-bottom: none;
}
/* Status Colors */
.channel-card.status-mapped {
border-left: 4px solid var(--success-color);
}
.channel-card.status-recommended {
border-left: 4px solid #ffc107;
}
.channel-card.status-unmapped {
border-left: 4px solid #ff5722;
}
/* Channel Info */
.channel-info {
display: flex;
align-items: center;
gap: 15px;
min-width: 300px;
}
.channel-logo {
width: 50px;
height: 50px;
border-radius: 8px;
object-fit: contain;
background: white;
padding: 5px;
border: 1px solid #dee2e6;
}
.channel-details h4 {
font-size: 1.1rem;
margin-bottom: 5px;
color: var(--dark-color);
}
.channel-id {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: var(--gray-color);
margin-bottom: 3px;
}
.channel-source {
font-size: 0.85rem;
color: var(--primary-color);
font-weight: 500;
}
/* Mapping Control */
.mapping-control {
flex: 1;
max-width: 400px;
display: flex;
align-items: center;
gap: 15px;
}
.epg-selector {
flex: 1;
padding: 10px 15px;
border: 2px solid #dee2e6;
border-radius: var(--border-radius);
font-size: 1rem;
background: white;
min-width: 200px;
}
.epg-selector:focus {
border-color: var(--primary-color);
outline: none;
}
.mapping-status {
min-width: 150px;
}
.status-badge {
padding: 6px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
display: inline-block;
}
.status-badge.mapped {
background: rgba(76, 201, 240, 0.2);
color: var(--success-color);
}
.status-badge.recommended {
background: rgba(255, 193, 7, 0.2);
color: #ffc107;
}
.status-badge.unmapped {
background: rgba(255, 87, 34, 0.2);
color: #ff5722;
}
.epg-preview {
font-size: 0.85rem;
color: var(--gray-color);
margin-top: 5px;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.epg-suggestions {
font-size: 0.85rem;
color: var(--gray-color);
margin-top: 5px;
}
.suggestion-tag {
display: inline-block;
background: #e9ecef;
padding: 3px 8px;
border-radius: 12px;
margin-right: 5px;
margin-bottom: 5px;
font-size: 0.8rem;
cursor: pointer;
transition: var(--transition);
}
.suggestion-tag:hover {
background: var(--primary-color);
color: white;
}
/* Bulk Actions */
.bulk-actions {
display: flex;
justify-content: center;
gap: 15px;
padding-top: 20px;
border-top: 1px solid #dee2e6;
flex-wrap: wrap;
}
.bulk-actions .btn {
min-width: 180px;
}
/* EPG Preview Modal */
.epg-preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 2000;
display: flex;
justify-content: center;
align-items: center;
display: none;
}
.epg-preview-content {
background: white;
border-radius: var(--border-radius);
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
padding: 30px;
}
.epg-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #dee2e6;
}
.epg-preview-body {
line-height: 1.8;
}
.epg-program {
padding: 15px;
border-bottom: 1px solid #eee;
}
.epg-program:last-child {
border-bottom: none;
}
.epg-program-time {
color: var(--primary-color);
font-weight: 600;
margin-bottom: 5px;
}
.epg-program-title {
font-size: 1.1rem;
margin-bottom: 8px;
}
.epg-program-desc {
color: var(--gray-color);
font-size: 0.95rem;
}
/* Empty States */
.no-channels-message {
text-align: center;
padding: 60px 20px;
color: var(--gray-color);
}
.no-channels-message i {
font-size: 3rem;
margin-bottom: 20px;
color: #dee2e6;
}
/* Auto-save Indicator */
.save-indicator {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--primary-color);
color: white;
padding: 10px 20px;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
display: none;
align-items: center;
gap: 10px;
z-index: 1000;
animation: slideInUp 0.3s ease;
}
@keyframes slideInUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Responsive */
@media (max-width: 992px) {
.channel-card {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.channel-info {
min-width: 100%;
}
.mapping-control {
max-width: 100%;
width: 100%;
}
.mapping-header {
flex-direction: column;
align-items: flex-start;
}
.header-stats {
width: 100%;
justify-content: space-between;
}
}
@media (max-width: 576px) {
.mapping-controls {
flex-direction: column;
align-items: flex-start;
}
.search-box {
min-width: 100%;
}
.filters {
flex-direction: column;
align-items: flex-start;
}
}
+760
View File
@@ -0,0 +1,760 @@
// 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() {
this.loadProviders();
this.setupEventListeners();
this.createSaveIndicator();
}
async loadProviders() {
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();
} catch (error) {
this.showError('Failed to load providers', error);
}
}
renderProviderSelector() {
const container = document.getElementById('epg-mapping-container');
container.innerHTML = `
<div class="epg-mapping-container">
<div class="mapping-header">
<div class="provider-selector">
<select id="epg-provider-select">
<option value="">Select a provider...</option>
${this.providers.map(p =>
`<option value="${p.name}">${p.label} (${p.name})</option>`
).join('')}
</select>
<button id="epg-load-provider" class="btn btn-primary">
<i class="fas fa-sync"></i> Load Channels
</button>
</div>
<div class="header-stats">
<span class="stat mapped"><i class="fas fa-check-circle"></i> Mapped: <span id="stat-mapped">0</span></span>
<span class="stat recommended"><i class="fas fa-star"></i> Recommended: <span id="stat-recommended">0</span></span>
<span class="stat unmapped"><i class="fas fa-question-circle"></i> Unmapped: <span id="stat-unmapped">0</span></span>
</div>
</div>
<div class="mapping-controls" style="display: none;" id="epg-controls">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="epg-search" placeholder="Search channels or EPG IDs...">
</div>
<div class="filters">
<label class="filter-checkbox">
<input type="checkbox" id="filter-mapped" checked>
<span>Mapped</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="filter-recommended" checked>
<span>Recommended</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="filter-unmapped" checked>
<span>Unmapped</span>
</label>
</div>
</div>
<div class="channel-mapping-list" id="channel-list" style="display: none;"></div>
<div class="bulk-actions" id="bulk-actions" style="display: none;">
<button id="btn-bulk-confirm" class="btn btn-success">
<i class="fas fa-check-double"></i> Confirm All Recommended
</button>
<button id="btn-save-changes" class="btn btn-primary">
<i class="fas fa-save"></i> Save Changes
</button>
<button id="btn-export-mappings" class="btn btn-secondary">
<i class="fas fa-download"></i> Export Mappings
</button>
<button id="btn-import-mappings" class="btn btn-secondary">
<i class="fas fa-upload"></i> Import Mappings
</button>
</div>
</div>
<!-- EPG Preview Modal -->
<div class="epg-preview-modal" id="epg-preview-modal">
<div class="epg-preview-content">
<div class="epg-preview-header">
<h3><i class="fas fa-tv"></i> EPG Preview</h3>
<button class="btn btn-sm btn-danger" id="close-preview">
<i class="fas fa-times"></i> Close
</button>
</div>
<div class="epg-preview-body" id="epg-preview-body"></div>
</div>
</div>
`;
this.setupDynamicEventListeners();
}
setupEventListeners() {
document.addEventListener('DOMContentLoaded', () => {
const tabs = document.querySelectorAll('.tab');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
if (tab.dataset.tab === 'epg-mapping') {
this.loadProviders();
}
});
});
});
}
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';
}
});
}
}
async loadProviderData(providerName) {
try {
this.showLoading(true);
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) throw new Error('Failed to load EPG channels');
const providerChannels = await channelsResponse.json();
const currentMappings = mappingsResponse.ok ? await mappingsResponse.json() : { mapping: {} };
const epgData = await epgResponse.json();
this.epgChannels = epgData.channels || [];
this.fuzzySet = new FuzzySet(this.epgChannels);
this.channelData = providerChannels.channels.map(channel => {
let currentEpgId = null;
const mappingValue = currentMappings.mapping?.[channel.id];
if (typeof mappingValue === 'string') {
currentEpgId = mappingValue;
} else if (mappingValue && mappingValue.epg_id) {
currentEpgId = mappingValue.epg_id;
}
const suggestions = this.getSuggestions(channel.name);
return {
...channel,
currentEpgId,
suggestions: suggestions.slice(0, 3),
status: this.calculateStatus(channel, currentEpgId, suggestions)
};
});
document.getElementById('epg-controls').style.display = 'flex';
document.getElementById('channel-list').style.display = 'block';
document.getElementById('bulk-actions').style.display = 'flex';
this.updateStats();
this.renderChannelList();
this.showLoading(false);
} catch (error) {
this.showError('Failed to load provider data', error);
this.showLoading(false);
}
}
getSuggestions(channelName) {
if (!this.fuzzySet || !channelName) return [];
const results = this.fuzzySet.get(channelName);
if (!results) return [];
return results.map(([score, epgId]) => ({
epgId,
score: Math.round(score * 100),
name: epgId
}));
}
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) {
container.innerHTML = `
<div class="no-channels-message">
<i class="fas fa-tv"></i>
<p>No channels found for this provider.</p>
</div>
`;
return;
}
const filteredChannels = this.getFilteredChannels();
container.innerHTML = filteredChannels.map(channel => `
<div class="channel-card status-${channel.status}" data-channel-id="${channel.id}">
<div class="channel-info">
${channel.logo ? `<img src="${channel.logo}" alt="${this.escapeHtml(channel.name)}" class="channel-logo" onerror="this.style.display='none'">` : ''}
<div class="channel-details">
<h4>${this.escapeHtml(channel.name)}</h4>
<div class="channel-id">ID: ${this.escapeHtml(channel.id)}</div>
<div class="channel-source">${this.escapeHtml(this.currentProvider)}</div>
</div>
</div>
<div class="mapping-control">
<select class="epg-selector" data-channel="${this.escapeHtml(channel.id)}">
<option value="">-- Select EPG ID --</option>
<option value="${this.escapeHtml(channel.id)}" ${channel.currentEpgId === channel.id ? 'selected' : ''}>
${this.escapeHtml(channel.id)} (Use channel ID)
</option>
${this.epgChannels.map(epgId => `
<option value="${this.escapeHtml(epgId)}" ${channel.currentEpgId === epgId ? 'selected' : ''}>
${this.escapeHtml(epgId)}
</option>
`).join('')}
</select>
<div class="mapping-status">
<span class="status-badge ${channel.status}">
${this.getStatusIcon(channel.status)} ${channel.status.charAt(0).toUpperCase() + channel.status.slice(1)}
</span>
${channel.status === 'recommended' && channel.suggestions.length > 0 ? `
<div class="epg-suggestions">
<small>Suggestions:</small>
${channel.suggestions.map(s => `
<span class="suggestion-tag"
data-epg-id="${this.escapeHtml(s.epgId)}"
data-channel="${this.escapeHtml(channel.id)}"
title="Match: ${s.score}%">
${this.escapeHtml(s.epgId)}
</span>
`).join('')}
</div>
` : ''}
${channel.status === 'unmapped' && channel.suggestions.length > 0 ? `
<div class="epg-suggestions">
<small>Low confidence matches:</small>
${channel.suggestions.slice(0, 2).map(s => `
<span class="suggestion-tag"
data-epg-id="${this.escapeHtml(s.epgId)}"
data-channel="${this.escapeHtml(channel.id)}"
title="Match: ${s.score}%">
${this.escapeHtml(s.epgId)} (${s.score}%)
</span>
`).join('')}
</div>
` : ''}
${channel.currentEpgId ? `
<div class="epg-preview">
<a href="#" class="preview-link" data-epg-id="${this.escapeHtml(channel.currentEpgId)}">
<i class="fas fa-eye"></i> Preview EPG
</a>
</div>
` : ''}
</div>
</div>
</div>
`).join('');
this.setupChannelEventListeners();
}
setupChannelEventListeners() {
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('.suggestion-tag').forEach(tag => {
tag.addEventListener('click', (e) => {
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);
}
});
});
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;
channel.status = epgId ? 'mapped' : 'unmapped';
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)}`;
}
const previewContainer = card.querySelector('.epg-preview');
if (previewContainer) {
if (epgId) {
previewContainer.innerHTML = `
<a href="#" class="preview-link" data-epg-id="${this.escapeHtml(epgId)}">
<i class="fas fa-eye"></i> Preview EPG
</a>
`;
const newLink = previewContainer.querySelector('.preview-link');
if (newLink) {
newLink.addEventListener('click', async (e) => {
e.preventDefault();
await this.showEPGPreview(epgId);
});
}
} else {
previewContainer.innerHTML = '';
}
}
}
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 '<i class="fas fa-check"></i>';
case 'recommended': return '<i class="fas fa-star"></i>';
case 'unmapped': return '<i class="fas fa-question"></i>';
default: return '<i class="fas fa-circle"></i>';
}
}
async showEPGPreview(epgId) {
try {
const response = await fetch(`${this.apiBase}/api/epg/preview/${encodeURIComponent(epgId)}`);
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 = `
<h4>EPG Data for: ${this.escapeHtml(epgId)}</h4>
${epgData.programs && epgData.programs.length > 0 ? `
<div class="epg-programs">
${epgData.programs.slice(0, 5).map(program => `
<div class="epg-program">
<div class="epg-program-time">
${new Date(program.start * 1000).toLocaleString()} -
${new Date(program.end * 1000).toLocaleTimeString()}
</div>
<div class="epg-program-title">
<strong>${this.escapeHtml(program.title || 'No title')}</strong>
${program.episode_name ? `<br><em>${this.escapeHtml(program.episode_name)}</em>` : ''}
</div>
<div class="epg-program-desc">
${this.escapeHtml(program.description || 'No description available.')}
</div>
</div>
`).join('')}
</div>
` : '<p>No program data available for this channel.</p>'}
`;
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) {
mappings[channel.id] = channel.currentEpgId;
}
});
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 ? '<i class="fas fa-check-circle"></i>' :
isSuccess === false ? '<i class="fas fa-exclamation-circle"></i>' :
'<i class="fas fa-sync fa-spin"></i>'}
${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';
}
}
showLoading(show) {
const container = document.getElementById('epg-mapping-container');
if (!container) return;
if (show) {
container.innerHTML = `
<div class="empty-state">
<i class="fas fa-spinner fa-spin loader"></i>
<p>Loading EPG data...</p>
</div>
`;
}
}
showError(message, error) {
const container = document.getElementById('epg-mapping-container');
if (container) {
container.innerHTML = `
<div class="alert alert-error">
<i class="fas fa-exclamation-circle"></i>
<div>
<strong>${message}</strong>
<p>${this.escapeHtml(error.message || String(error))}</p>
<button class="btn btn-sm btn-primary" onclick="window.epgMappingManager.loadProviders()">
<i class="fas fa-redo"></i> Retry
</button>
</div>
</div>
`;
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Initialize when page loads
let epgMappingManager;
document.addEventListener('DOMContentLoaded', () => {
epgMappingManager = new EPGMappingManager();
window.epgMappingManager = epgMappingManager;
});
+15
View File
@@ -0,0 +1,15 @@
// Debounce utility for auto-save
function debounce(func, wait, immediate) {
let timeout;
return function() {
const context = this, args = arguments;
const later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
+127
View File
@@ -0,0 +1,127 @@
// Simplified FuzzySet implementation for EPG matching
class FuzzySet {
constructor(arr, useLevenshtein = true, gramSizeLower = 2, gramSizeUpper = 3) {
this.exactSet = {};
this.matchDict = {};
this.items = {};
this.useLevenshtein = useLevenshtein;
this.gramSizeLower = gramSizeLower;
this.gramSizeUpper = gramSizeUpper;
if (arr) {
arr.forEach(item => this.add(item));
}
}
add(value) {
const normalized = this._normalize(value);
if (this.items[normalized]) return;
const index = Object.keys(this.items).length;
this.items[normalized] = [value, 0];
// Add to exact set for direct matching
this.exactSet[normalized] = [index, 0];
// Generate gram keys
const gramCount = this.gramSizeUpper - this.gramSizeLower + 1;
for (let gramSize = this.gramSizeLower; gramSize <= this.gramSizeUpper; gramSize++) {
const grams = this._gramify(normalized, gramSize);
grams.forEach(gram => {
if (!this.matchDict[gram]) this.matchDict[gram] = [];
this.matchDict[gram].push([index, gramCount]);
});
}
}
get(value) {
const normalized = this._normalize(value);
// Check exact match first
if (this.exactSet[normalized]) {
const [index, distance] = this.exactSet[normalized];
return [[1.0, this.items[normalized][0]]];
}
const results = [];
const gramResults = {};
const minGram = this.gramSizeLower;
const maxGram = this.gramSizeUpper;
// Get gram matches
for (let gramSize = minGram; gramSize <= maxGram; gramSize++) {
const grams = this._gramify(normalized, gramSize);
grams.forEach(gram => {
if (this.matchDict[gram]) {
this.matchDict[gram].forEach(([index, gramCount]) => {
if (!gramResults[index]) gramResults[index] = 0;
gramResults[index] += 1 / gramCount;
});
}
});
}
// Calculate scores
Object.keys(gramResults).forEach(index => {
const score = gramResults[index] / this._gramify(normalized, 2).length;
if (score > 0) {
results.push([score, this.items[Object.keys(this.items)[index]][0]]);
}
});
// Sort by score descending
results.sort((a, b) => b[0] - a[0]);
// Use Levenshtein distance for top results if enabled
if (this.useLevenshtein && results.length > 0) {
results.forEach(result => {
const normalizedItem = this._normalize(result[1]);
const distance = this._levenshtein(normalized, normalizedItem);
const maxLen = Math.max(normalized.length, normalizedItem.length);
result[0] = 1 - (distance / maxLen);
});
results.sort((a, b) => b[0] - a[0]);
}
return results.slice(0, 5); // Return top 5 matches
}
_normalize(str) {
return str.toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
}
_gramify(str, gramSize) {
const normalized = this._normalize(str);
const grams = [];
for (let i = 0; i < normalized.length - gramSize + 1; i++) {
grams.push(normalized.substring(i, i + gramSize));
}
return grams;
}
_levenshtein(a, b) {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
const cost = a[j - 1] === b[i - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost
);
}
}
return matrix[b.length][a.length];
}
}
+233 -8
View File
@@ -74,31 +74,74 @@ class UltimateService:
def _load_config_html(self):
"""Load the web interface HTML template with embedded CSS and JS"""
base_dir = os.path.dirname(os.path.abspath(__file__))
web_dir = os.path.join(base_dir, 'resources', 'web')
# Define file paths
html_path = os.path.join(base_dir, 'resources', 'web', 'config.html')
css_path = os.path.join(base_dir, 'resources', 'web', 'config.css')
js_path = os.path.join(base_dir, 'resources', 'web', 'config.js')
html_path = os.path.join(web_dir, 'config.html')
css_path = os.path.join(web_dir, 'config.css')
js_path = os.path.join(web_dir, 'config.js')
# NEW: EPG mapping files
epg_css_path = os.path.join(web_dir, 'epg_mapping.css')
epg_js_path = os.path.join(web_dir, 'epg_mapping.js')
fuzzyset_path = os.path.join(web_dir, 'lib', 'fuzzyset.js')
debounce_path = os.path.join(web_dir, 'lib', 'debounce.js')
try:
# Load HTML
with open(html_path, 'r', encoding='utf-8') as f:
html = f.read()
# Load CSS
# Load CSS files
with open(css_path, 'r', encoding='utf-8') as f:
css = f.read()
# Load JS
# NEW: Load EPG CSS
with open(epg_css_path, 'r', encoding='utf-8') as f:
epg_css = f.read()
# Load JS files
with open(js_path, 'r', encoding='utf-8') as f:
js = f.read()
# Simple replacements - we control the files so we know what's in them
# NEW: Load EPG JS and libraries
with open(epg_js_path, 'r', encoding='utf-8') as f:
epg_js = f.read()
with open(fuzzyset_path, 'r', encoding='utf-8') as f:
fuzzyset_js = f.read()
with open(debounce_path, 'r', encoding='utf-8') as f:
debounce_js = f.read()
# Combine all CSS
combined_css = f"{css}\n\n/* EPG Mapping CSS */\n{epg_css}"
# Combine all JS (with proper order)
combined_js = f"""{debounce_js}
/* FuzzySet Library */
{fuzzyset_js}
/* Main Config JS */
{js}
/* EPG Mapping JS */
{epg_js}
"""
# Replace in HTML
html = html.replace('<link rel="stylesheet" href="config.css">',
f'<style>\n{css}\n</style>')
f'<style>\n{combined_css}\n</style>')
html = html.replace('<script src="config.js"></script>',
f'<script>\n{js}\n</script>')
f'<script>\n{combined_js}\n</script>')
# Remove the additional script tags we added (they're now embedded)
html = html.replace('<link rel="stylesheet" href="epg_mapping.css">', '')
html = html.replace('<script src="lib/fuzzyset.js"></script>', '')
html = html.replace('<script src="lib/debounce.js"></script>', '')
html = html.replace('<script src="epg_mapping.js"></script>', '')
return html
@@ -2043,6 +2086,188 @@ class UltimateService:
response.content_type = 'text/html; charset=utf-8'
return self.config_html
@self.app.route("/api/epg/xmltv-channels", methods=["GET"])
def get_epg_xmltv_channels():
"""Get all unique channel IDs from EPG XML file"""
try:
from streaming_providers.base.epg.epg_manager import EPGManager
except ImportError:
response.status = 404
return {"error": "EPG module not available"}
try:
epg_manager = EPGManager()
# Get the cached EPG file path
cache = epg_manager.cache
xml_path = cache.get_cached_file_path()
if not xml_path:
response.status = 404
return {"error": "EPG file not available. Please check EPG URL configuration."}
# Parse XML to get channel IDs
import xml.etree.ElementTree as ET
import gzip
channel_ids = set()
def open_xml_file(file_path):
if file_path.endswith('.gz'):
return gzip.open(file_path, 'rt', encoding='utf-8')
else:
return open(file_path, 'r', encoding='utf-8')
with open_xml_file(xml_path) as xml_file:
# Use iterparse for memory efficiency
context = ET.iterparse(xml_file, events=('start',))
for event, elem in context:
if elem.tag == 'channel':
channel_id = elem.get('id')
if channel_id:
channel_ids.add(channel_id)
elem.clear()
return {"channels": sorted(list(channel_ids))}
except Exception as e:
logger.error(f"Error getting EPG channels: {e}")
response.status = 500
return {"error": f"Failed to parse EPG file: {str(e)}"}
@self.app.route("/api/providers/<provider>/epg-mapping", methods=["GET"])
def get_epg_mapping(provider):
"""Get current EPG mapping for a provider"""
try:
from streaming_providers.base.utils.vfs import VFS
except ImportError:
# If VFS is not available, return empty mapping
return {
"provider": provider,
"mapping": {},
"exists": False
}
try:
mapping_file = f"{provider}_epg_mapping.json"
vfs = VFS(addon_subdir="")
if vfs.exists(mapping_file):
mapping_data = vfs.read_json(mapping_file)
if mapping_data:
return {
"provider": provider,
"mapping": mapping_data,
"exists": True
}
# Return empty mapping if file doesn't exist
return {
"provider": provider,
"mapping": {},
"exists": False
}
except Exception as e:
logger.error(f"Error getting EPG mapping for {provider}: {e}")
response.status = 500
return {"error": f"Failed to load mapping: {str(e)}"}
@self.app.route("/api/providers/<provider>/epg-mapping", methods=["POST"])
def save_epg_mapping(provider):
"""Save EPG mapping for a provider"""
try:
from streaming_providers.base.utils.vfs import VFS
except ImportError:
response.status = 500
return {"error": "VFS module not available"}
try:
# Get JSON data from request body using Bottle's request object
try:
mapping_data = request.json.get("mapping", {}) if request.json else {}
except Exception as json_err:
logger.error(f"Invalid JSON in request body: {json_err}")
response.status = 400
return {"error": "Invalid JSON in request body"}
# Add provider name to mapping file
full_mapping = {
"_provider_name": provider,
**mapping_data
}
# Save to file
vfs = VFS(addon_subdir="")
mapping_file = f"{provider}_epg_mapping.json"
success = vfs.write_json(mapping_file, full_mapping)
if success:
# Clear mapping cache if it exists
try:
from streaming_providers.base.epg.epg_mapping import EPGMapping
mapping_manager = EPGMapping()
mapping_manager.reload_mapping(provider)
except ImportError:
# EPGMapping not available, skip cache reload
pass
except Exception as reload_err:
logger.warning(f"Could not reload mapping cache: {reload_err}")
return {
"success": True,
"message": f"Mapping saved for {provider}"
}
else:
response.status = 500
return {"error": "Failed to save mapping file"}
except Exception as e:
logger.error(f"Error saving EPG mapping for {provider}: {e}")
response.status = 500
return {"error": f"Failed to save mapping: {str(e)}"}
@self.app.route("/api/epg/preview/<epg_id>", methods=["GET"])
def get_epg_preview(epg_id):
"""Get EPG preview data for a channel"""
# Import at the top of the function to avoid scope issues
from datetime import datetime, timedelta
try:
from streaming_providers.base.epg.epg_manager import EPGManager
except ImportError:
# If EPG module is not available, return empty data
return {
"epg_id": epg_id,
"programs": []
}
try:
# For demo - in reality, you'd parse the XML for this channel
# This is a simplified version
now = datetime.now()
end_time = now + timedelta(hours=1)
return {
"epg_id": epg_id,
"programs": [
{
"title": "Sample Program",
"start": int(now.timestamp()),
"end": int(end_time.timestamp()),
"description": "Sample program description."
}
]
}
except Exception as e:
logger.error(f"Error getting EPG preview for {epg_id}: {e}")
response.status = 500
return {"error": f"Failed to get EPG data: {str(e)}"}
@self.app.route('/')
def serve_root():
"""Redirect root to config page"""