/** * 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.treeData = null; // { type, sourceId, groups: [{ id, name, items: [] }] } this.hiddenSet = new Set(); // Set of hidden item keys this.expandedGroups = new Set(); // Set of expanded group IDs 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 */ /** * Load content tree for a source */ async loadContentTree(sourceId) { this.contentTree.innerHTML = 'Loading...
'; this.treeData = { type: 'channels', sourceId, groups: [] }; this.expandedGroups.clear(); 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; 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); this.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') { if (ch.category_id && categoryMap[ch.category_id]) { groupName = categoryMap[ch.category_id]; } } else { groupName = ch.category_name || ch.groupTitle || 'Uncategorized'; } if (!groups[groupName]) { groups[groupName] = []; } // Normalize channel object const channelId = ch.stream_id || ch.id || ch.url; const channelName = ch.name || ch.tvgName || 'Unknown'; groups[groupName].push({ id: channelId, name: channelName, original: ch, type: 'channel' }); }); // Convert to array this.treeData.groups = Object.keys(groups).sort().map(name => ({ id: name, // generic group ID name: name, type: 'group', items: groups[name] })); this.renderTree(); } catch (err) { console.error('Error loading content tree:', err); this.contentTree.innerHTML = 'Error loading content
'; } } /** * Render the full tree based on current state */ renderTree() { if (!this.treeData || !this.treeData.groups.length) { this.contentTree.innerHTML = 'No content found
'; return; } const html = this.treeData.groups.map(group => this.getGroupHtml(group)).join(''); this.contentTree.innerHTML = html; // Attach event listeners this.attachTreeListeners(this.contentTree); } /** * Get HTML for a group (and its items if expanded) */ getGroupHtml(group) { const isExpanded = this.expandedGroups.has(group.id); // Group checkbox is checked if ANY child is visible (derived state) const hasVisibleChild = group.items.some(item => !this.hiddenSet.has(`${item.type}:${item.id}`)); const checked = hasVisibleChild; let itemsHtml = ''; if (isExpanded) { itemsHtml = `Loading movie categories...
'; this.treeData = { type: 'movies', sourceId, groups: [] }; try { const source = await API.sources.getById(sourceId); if (source.type !== 'xtream') { this.contentTree.innerHTML = 'Movie categories are only available for Xtream sources
'; return; } const categories = await API.proxy.xtream.vodCategories(sourceId); if (!categories || categories.length === 0) { this.contentTree.innerHTML = 'No movie categories found
'; return; } const hiddenItems = await API.channels.getHidden(sourceId); this.hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`)); // Create a single "Movies" group or flatten? // The original UI rendered a flat list of categories. // Better to stick to "Group -> Items" structure, or just wrap them in a pseudo-group? // Original: rendered checkboxes directly. // Let's adopt the treeData structure but with a single root group or flat items? // To support generic renderTree, we can put them in a "Categories" group or just render them as items. // Let's update renderTree to support flat list if groups is empty? // Or just put them in one "All Categories" group that is auto-expanded. this.treeData.groups = [{ id: 'all_categories', name: 'Categories', type: 'group', items: categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).map(cat => ({ id: cat.category_id, name: cat.category_name, type: 'vod_category', original: cat })) }]; // Auto expand this.expandedGroups.add('all_categories'); this.renderTree(); } catch (err) { console.error('Error loading movie categories:', err); this.contentTree.innerHTML = 'Error loading movie categories
'; } } /** * Load series categories tree for a source */ async loadSeriesCategoriesTree(sourceId) { this.contentTree.innerHTML = 'Loading series categories...
'; this.treeData = { type: 'series', sourceId, groups: [] }; try { const source = await API.sources.getById(sourceId); if (source.type !== 'xtream') { this.contentTree.innerHTML = 'Series categories are only available for Xtream sources
'; return; } const categories = await API.proxy.xtream.seriesCategories(sourceId); if (!categories || categories.length === 0) { this.contentTree.innerHTML = 'No series categories found
'; return; } const hiddenItems = await API.channels.getHidden(sourceId); this.hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`)); this.treeData.groups = [{ id: 'all_series_categories', name: 'Categories', type: 'group', items: categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).map(cat => ({ id: cat.category_id, name: cat.category_name, type: 'series_category', original: cat })) }]; this.expandedGroups.add('all_series_categories'); this.renderTree(); } catch (err) { console.error('Error loading series categories:', err); this.contentTree.innerHTML = 'Error loading series categories
'; } } /** * Toggle visibility of a single item (LOCAL STATE ONLY - use Save to persist) * Checked = show (remove from hidden), Unchecked = hide (add to hidden) */ toggleVisibility(checkbox) { const itemType = checkbox.dataset.type; const itemId = checkbox.dataset.id; const isVisible = checkbox.checked; // Update local state only (will be persisted when Save is clicked) const key = `${itemType}:${itemId}`; if (isVisible) { this.hiddenSet.delete(key); } else { this.hiddenSet.add(key); } // Update parent group checkbox to reflect derived state const groupEl = checkbox.closest('.content-group'); if (groupEl) { const groupCheckbox = groupEl.querySelector('.group-checkbox'); if (groupCheckbox) { const groupId = groupEl.dataset.groupId; const group = this.treeData.groups.find(g => g.id === groupId); if (group) { const hasVisibleChild = group.items.some(item => !this.hiddenSet.has(`${item.type}:${item.id}`)); groupCheckbox.checked = hasVisibleChild; } } } } /** * Toggle all children of a group (LOCAL STATE ONLY - use Save to persist) */ toggleGroupChildren(groupCb) { const groupName = groupCb.dataset.id; const group = this.treeData.groups.find(g => g.name === groupName); if (!group) return; const isChecked = groupCb.checked; // Update state for all children group.items.forEach(item => { const key = `${item.type}:${item.id}`; if (isChecked) { this.hiddenSet.delete(key); } else { this.hiddenSet.add(key); } }); // Re-render group to update all checkboxes const groupEl = this.contentTree.querySelector(`.content-group[data-group-id="${CSS.escape(group.id)}"]`); if (groupEl) { groupEl.outerHTML = this.getGroupHtml(group); const newEl = this.contentTree.querySelector(`.content-group[data-group-id="${CSS.escape(group.id)}"]`); if (newEl) this.attachTreeListeners(newEl); } } /** * Set visibility for all items (LOCAL STATE ONLY - use Save to persist) */ setAllVisibility(visible) { if (!this.treeData || !this.treeData.groups) return; // Update state for all items this.treeData.groups.forEach(group => { group.items.forEach(item => { const key = `${item.type}:${item.id}`; if (visible) { this.hiddenSet.delete(key); } else { this.hiddenSet.add(key); } }); }); // Re-render to reflect changes this.renderTree(); } /** * Save all content visibility changes to the server */ async saveContentChanges() { if (!this.treeData) { alert('No content loaded to save'); return; } const saveBtn = document.getElementById('content-save'); if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = '⏳ Saving...'; } try { const sourceId = this.treeData.sourceId; const itemsToShow = []; const itemsToHide = []; // Collect all items and determine their visibility this.treeData.groups.forEach(group => { group.items.forEach(item => { const key = `${item.type}:${item.id}`; const isHidden = this.hiddenSet.has(key); if (isHidden) { itemsToHide.push({ sourceId, itemType: item.type, itemId: item.id }); } else { itemsToShow.push({ sourceId, itemType: item.type, itemId: item.id }); } }); }); // Execute bulk operations const promises = []; if (itemsToShow.length > 0) { promises.push(API.channels.bulkShow(itemsToShow)); } if (itemsToHide.length > 0) { promises.push(API.channels.bulkHide(itemsToHide)); } await Promise.all(promises); // Sync Channel List if (window.app?.channelList) { await window.app.channelList.loadHiddenItems(); window.app.channelList.render(); } if (saveBtn) { saveBtn.textContent = '✓ Saved!'; setTimeout(() => { saveBtn.textContent = '💾 Save Changes'; saveBtn.disabled = false; }, 1500); } } catch (err) { console.error('Error saving content changes:', err); alert('Failed to save changes: ' + err.message); if (saveBtn) { saveBtn.textContent = '💾 Save Changes'; saveBtn.disabled = false; } } } } // Export window.SourceManager = SourceManager;