mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-11 02:12:31 +02:00
Add web config
This commit is contained in:
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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"""
|
||||
|
||||
Reference in New Issue
Block a user