/** * Source Manager Component * Handles adding, editing, and deleting sources (Xtream, M3U, EPG) */ class SourceManager { constructor() { this.xtreamList = document.getElementById('xtream-list'); this.m3uList = document.getElementById('m3u-list'); this.epgList = document.getElementById('epg-list'); // Content browser state this.contentType = 'channels'; // 'channels' or 'movies' this.init(); } init() { // Add source buttons document.getElementById('add-xtream').addEventListener('click', () => this.showAddModal('xtream')); document.getElementById('add-m3u').addEventListener('click', () => this.showAddModal('m3u')); document.getElementById('add-epg').addEventListener('click', () => this.showAddModal('epg')); // Initialize content browser this.initContentBrowser(); } /** * Load and display all sources */ async loadSources() { try { const sources = await API.sources.getAll(); this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream'); this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u'); this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg'); } catch (err) { console.error('Error loading sources:', err); } } /** * Render source list */ renderSourceList(container, sources, type) { if (sources.length === 0) { container.innerHTML = `
No ${type.toUpperCase()} sources configured
`; return; } const icons = { xtream: '📡', m3u: '📋', epg: '📺' }; container.innerHTML = sources.map(source => `Select a source to view ${typeLabel}
`; return; } if (this.contentType === 'movies') { this.loadMovieCategoriesTree(parseInt(sourceId)); } else if (this.contentType === 'series') { this.loadSeriesCategoriesTree(parseInt(sourceId)); } else { this.loadContentTree(parseInt(sourceId)); } } /** * Load sources into content browser dropdown */ async loadContentSources() { try { const sources = await API.sources.getAll(); const select = document.getElementById('content-source-select'); if (!select) return; // Keep the placeholder option select.innerHTML = ''; sources.filter(s => s.type === 'xtream' || s.type === 'm3u').forEach(source => { select.innerHTML += ``; }); } catch (err) { console.error('Error loading content sources:', err); } } /** * Load content tree for a source * Checked = Visible, Unchecked = Hidden */ async loadContentTree(sourceId) { this.contentTree.innerHTML = 'Loading...
'; try { const source = await API.sources.getById(sourceId); let channels = []; let categoryMap = {}; if (source.type === 'xtream') { // Run sequentially to avoid overwhelming the provider const categories = await API.proxy.xtream.liveCategories(sourceId); const streams = await API.proxy.xtream.liveStreams(sourceId); channels = streams; // Create map of category_id -> category_name categories.forEach(cat => { categoryMap[cat.category_id] = cat.category_name; }); } else if (source.type === 'm3u') { const m3uData = await API.proxy.m3u.get(sourceId); channels = m3uData.channels || []; } // Get currently hidden items const hiddenItems = await API.channels.getHidden(sourceId); const hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`)); // Group channels const groups = {}; channels.forEach(ch => { let groupName = 'Uncategorized'; if (source.type === 'xtream') { // Use category map for Xtream if (ch.category_id && categoryMap[ch.category_id]) { groupName = categoryMap[ch.category_id]; } } else { // Use existing fields for M3U groupName = ch.category_name || ch.groupTitle || 'Uncategorized'; } if (!groups[groupName]) { groups[groupName] = []; } groups[groupName].push(ch); }); // Render tree - checked = visible, unchecked = hidden let html = ''; Object.keys(groups).sort().forEach(groupName => { const groupHidden = hiddenSet.has(`group:${groupName}`); const groupChannels = groups[groupName]; html += `No channels found
'; // Toggle group collapse on header click this.contentTree.querySelectorAll('.content-group-header').forEach(header => { header.addEventListener('click', () => { const group = header.closest('.content-group'); group.classList.toggle('collapsed'); }); }); // Attach change listeners for visibility toggling this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.addEventListener('change', () => this.toggleVisibility(cb)); }); // Group checkbox toggles all children (BULK) this.contentTree.querySelectorAll('.group-checkbox').forEach(groupCb => { groupCb.addEventListener('change', () => this.toggleGroupChildren(groupCb)); }); } catch (err) { console.error('Error loading content tree:', err); this.contentTree.innerHTML = 'Error loading content
'; } } /** * Load movie categories tree for a source * Checked = Visible, Unchecked = Hidden */ async loadMovieCategoriesTree(sourceId) { this.contentTree.innerHTML = 'Loading movie categories...
'; try { const source = await API.sources.getById(sourceId); if (source.type !== 'xtream') { this.contentTree.innerHTML = 'Movie categories are only available for Xtream sources
'; return; } // Fetch VOD categories const categories = await API.proxy.xtream.vodCategories(sourceId); if (!categories || categories.length === 0) { this.contentTree.innerHTML = 'No movie categories found
'; return; } // Get currently hidden items const hiddenItems = await API.channels.getHidden(sourceId); const hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`)); // Render categories - checked = visible, unchecked = hidden let html = 'Error loading movie categories
'; } } /** * Load series categories tree for a source * Checked = Visible, Unchecked = Hidden */ async loadSeriesCategoriesTree(sourceId) { this.contentTree.innerHTML = 'Loading series categories...
'; try { const source = await API.sources.getById(sourceId); if (source.type !== 'xtream') { this.contentTree.innerHTML = 'Series categories are only available for Xtream sources
'; return; } // Fetch series categories const categories = await API.proxy.xtream.seriesCategories(sourceId); if (!categories || categories.length === 0) { this.contentTree.innerHTML = 'No series categories found
'; return; } // Get currently hidden items const hiddenItems = await API.channels.getHidden(sourceId); const hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`)); // Render categories - checked = visible, unchecked = hidden let html = 'Error loading series categories
'; } } /** * Toggle visibility of a single item * Checked = show (remove from hidden), Unchecked = hide (add to hidden) */ async toggleVisibility(checkbox) { const sourceId = parseInt(checkbox.dataset.sourceId); const itemType = checkbox.dataset.type; const itemId = checkbox.dataset.id; const isVisible = checkbox.checked; try { // Fire API call (don't await for faster UI response) const apiCall = isVisible ? API.channels.show(sourceId, itemType, itemId) : API.channels.hide(sourceId, itemType, itemId); // For VOD categories, don't refresh channel list (just let API complete in background) if (itemType === 'vod_category' || itemType === 'series_category') { apiCall.catch(err => { console.error(`Error toggling ${itemType} visibility:`, err); checkbox.checked = !isVisible; // Revert on error }); return; } // For channels/groups, await and refresh await apiCall; // Refresh channel list if visible if (window.app?.channelList) { await window.app.channelList.loadHiddenItems(); window.app.channelList.render(); } } catch (err) { console.error('Error toggling visibility:', err); // Revert checkbox on error checkbox.checked = !isVisible; } } /** * Toggle all children of a group efficiently */ async toggleGroupChildren(groupCb) { const group = groupCb.closest('.content-group'); const channelCheckboxes = group.querySelectorAll('.channel-checkbox'); const isChecked = groupCb.checked; const itemsToUpdate = []; // Identify items that need changing channelCheckboxes.forEach(chCb => { if (chCb.checked !== isChecked) { chCb.checked = isChecked; itemsToUpdate.push({ sourceId: parseInt(chCb.dataset.sourceId), itemType: chCb.dataset.type, itemId: chCb.dataset.id }); } }); if (itemsToUpdate.length === 0) return; try { if (isChecked) { await API.channels.bulkShow(itemsToUpdate); } else { await API.channels.bulkHide(itemsToUpdate); } // Refresh channel list if (window.app?.channelList) { await window.app.channelList.loadHiddenItems(); window.app.channelList.render(); } } catch (err) { console.error('Error toggling group children:', err); // We might want to revert UI here, but it's complex for bulk items } } /** * Set visibility for all items */ async setAllVisibility(visible) { const sourceId = this.contentSourceSelect?.value; if (!sourceId) return; const checkboxes = this.contentTree.querySelectorAll('input[type="checkbox"]'); const items = []; checkboxes.forEach(cb => { if (cb.checked !== visible) { items.push({ sourceId: parseInt(cb.dataset.sourceId), itemType: cb.dataset.type, itemId: cb.dataset.id }); cb.checked = visible; } }); if (items.length === 0) return; try { if (visible) { // Show all - use bulk API await API.channels.bulkShow(items); } else { // Hide all - use bulk API await API.channels.bulkHide(items); } // Refresh channel list if (window.app?.channelList) { await window.app.channelList.loadHiddenItems(); window.app.channelList.render(); } } catch (err) { console.error('Error setting all visibility:', err); alert('Failed to update visibility'); } } } // Export window.SourceManager = SourceManager;