From 196b9520b7b2f5b256d7d03e3c64f42fbb3a9803 Mon Sep 17 00:00:00 2001 From: Trevor Mears Date: Fri, 16 Jan 2026 17:00:57 -0800 Subject: [PATCH] feat: add large M3U playlist warning (50K+ channels) --- public/js/api.js | 2 + public/js/components/SourceManager.js | 105 ++++++++++++++++++++++++++ server/routes/sources.js | 61 +++++++++++++++ server/services/m3uParser.js | 43 ++++++++++- 4 files changed, 210 insertions(+), 1 deletion(-) diff --git a/public/js/api.js b/public/js/api.js index b6766ef..77b1306 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -60,6 +60,8 @@ const API = { test: (id) => API.request('POST', `/sources/${id}/test`), sync: (id) => API.request('POST', `/sources/${id}/sync`), // Manual sync getStatus: () => API.request('GET', '/sources/status'), // Get all statuses + estimate: (id) => API.request('GET', `/sources/${id}/estimate`), // Estimate M3U size + estimateByUrl: (url, type) => API.request('POST', '/sources/estimate', { url, type }), // Estimate by URL (before creation) }, // Channels (hidden items) diff --git a/public/js/components/SourceManager.js b/public/js/components/SourceManager.js index 499298b..e5e00f9 100644 --- a/public/js/components/SourceManager.js +++ b/public/js/components/SourceManager.js @@ -33,6 +33,61 @@ class SourceManager { this.pollSyncStatus(); } + /** + * Show a styled warning modal with Cancel/Proceed buttons + * @param {Object} options - { title, message, details, proceedText, cancelText } + * @returns {Promise} - Resolves true if user clicks Proceed, false if Cancel + */ + showWarningModal({ title, message, details = '', proceedText = 'Proceed', cancelText = 'Cancel' }) { + return new Promise((resolve) => { + const modal = document.getElementById('modal'); + const modalTitle = document.getElementById('modal-title'); + const modalBody = document.getElementById('modal-body'); + const modalFooter = document.getElementById('modal-footer'); + + modalTitle.textContent = title; + + modalBody.innerHTML = ` +
+
+ + + +
+

${message}

+ ${details ? `

${details}

` : ''} +
+ `; + + modalFooter.innerHTML = ` + + + `; + + modal.classList.add('active'); + + const cleanup = () => { + modal.classList.remove('active'); + modal.querySelector('.modal-close').onclick = null; + }; + + document.getElementById('warning-cancel').onclick = () => { + cleanup(); + resolve(false); + }; + + document.getElementById('warning-proceed').onclick = () => { + cleanup(); + resolve(true); + }; + + modal.querySelector('.modal-close').onclick = () => { + cleanup(); + resolve(false); + }; + }); + } + /** * Poll sync status from the backend */ @@ -218,6 +273,28 @@ class SourceManager { } try { + // Check M3U size before creating (large playlist warning) + if (type === 'm3u') { + try { + const estimate = await API.sources.estimateByUrl(url, type); + if (estimate.needsWarning) { + const proceed = await this.showWarningModal({ + title: '⚠️ Large Playlist Warning', + message: `This playlist contains ${estimate.count.toLocaleString()} channels.`, + details: `Syncing may take several minutes and app performance may be impacted with large playlists.

Consider using a filtered M3U from your provider to include only channels you actually watch.`, + proceedText: 'Proceed Anyway', + cancelText: 'Cancel' + }); + if (!proceed) { + return; // Don't create the source + } + } + } catch (err) { + console.warn('[SourceManager] Could not estimate M3U size:', err.message); + // Continue with creation anyway + } + } + await API.sources.create({ type, name, url, username, password }); document.getElementById('modal').classList.remove('active'); await this.loadSources(); @@ -320,6 +397,34 @@ class SourceManager { if (icon) icon.classList.add('spin'); } + // Check M3U size before syncing (large playlist warning) + if (type === 'm3u') { + try { + const estimate = await API.sources.estimate(id); + if (estimate.needsWarning) { + const proceed = await this.showWarningModal({ + title: '⚠️ Large Playlist Warning', + message: `This playlist contains ${estimate.count.toLocaleString()} channels.`, + details: `Syncing may take several minutes and app performance may be impacted with large playlists.

Consider using a filtered M3U from your provider to include only channels you actually watch.`, + proceedText: 'Proceed Anyway', + cancelText: 'Cancel' + }); + if (!proceed) { + // Reset button state + if (btn) { + btn.disabled = false; + const icon = btn.querySelector('.icon'); + if (icon) icon.classList.remove('spin'); + } + return; + } + } + } catch (err) { + console.warn('[SourceManager] Could not estimate M3U size:', err.message); + // Continue with sync anyway + } + } + // 1. Trigger Backend Sync console.log(`[SourceManager] Triggering sync for source ${id}`); await API.sources.sync(id); diff --git a/server/routes/sources.js b/server/routes/sources.js index 615b4fb..3184fc2 100644 --- a/server/routes/sources.js +++ b/server/routes/sources.js @@ -4,6 +4,7 @@ const { sources } = require('../db'); const { getDb } = require('../db/sqlite'); const xtreamApi = require('../services/xtreamApi'); const syncService = require('../services/syncService'); +const m3uParser = require('../services/m3uParser'); // Get all sources router.get('/', async (req, res) => { @@ -204,6 +205,66 @@ router.post('/:id/test', async (req, res) => { } }); +// Estimate M3U playlist size (for large playlist warning) +const M3U_LARGE_THRESHOLD = 50000; + +// Estimate by URL (for new sources before creation) +router.post('/estimate', async (req, res) => { + try { + const { url, type } = req.body; + + if (!url) { + return res.status(400).json({ error: 'URL is required' }); + } + + // Only M3U sources need estimation + if (type !== 'm3u') { + return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD }); + } + + console.log(`[Sources] Estimating M3U size for URL...`); + const count = await m3uParser.countEntries(url); + console.log(`[Sources] M3U estimate: ${count} entries`); + + res.json({ + count, + needsWarning: count > M3U_LARGE_THRESHOLD, + threshold: M3U_LARGE_THRESHOLD + }); + } catch (err) { + console.error('Error estimating M3U size:', err); + res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message }); + } +}); + +// Estimate by source ID (for existing sources) +router.get('/:id/estimate', async (req, res) => { + try { + const source = await sources.getById(req.params.id); + if (!source) { + return res.status(404).json({ error: 'Source not found' }); + } + + // Only M3U sources need estimation + if (source.type !== 'm3u') { + return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD }); + } + + console.log(`[Sources] Estimating M3U size for ${source.name}...`); + const count = await m3uParser.countEntries(source.url); + console.log(`[Sources] M3U estimate: ${count} entries`); + + res.json({ + count, + needsWarning: count > M3U_LARGE_THRESHOLD, + threshold: M3U_LARGE_THRESHOLD + }); + } catch (err) { + console.error('Error estimating M3U size:', err); + res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message }); + } +}); + // Global Sync - sync all enabled sources router.post('/sync-all', async (req, res) => { try { diff --git a/server/services/m3uParser.js b/server/services/m3uParser.js index 433004a..fa59425 100644 --- a/server/services/m3uParser.js +++ b/server/services/m3uParser.js @@ -271,5 +271,46 @@ async function* fetchAndParseStreaming(url, batchSize = 500) { yield* parseStreaming(stream, batchSize); } -module.exports = { parse, parseExtinf, fetchAndParse, parseStreaming, fetchAndParseStreaming }; +/** + * Fast count of entries in an M3U playlist (for size estimation) + * Streams the file and counts #EXTINF lines without full parsing + * @param {string} url - URL of the M3U playlist + * @returns {Promise} Number of entries + */ +async function countEntries(url) { + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch playlist: ${response.status}`); + } + + let stream; + if (response.body && typeof response.body.pipe === 'function') { + stream = response.body; + } else if (response.body) { + stream = Readable.fromWeb(response.body); + } else { + return 0; + } + + return new Promise((resolve, reject) => { + let count = 0; + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + rl.on('line', (line) => { + if (line.startsWith('#EXTINF:')) { + count++; + } + }); + + rl.on('close', () => resolve(count)); + rl.on('error', reject); + }); +} + +module.exports = { parse, parseExtinf, fetchAndParse, parseStreaming, fetchAndParseStreaming, countEntries };