diff --git a/src/xmltv.js b/src/xmltv.js index cdba550..449c044 100644 --- a/src/xmltv.js +++ b/src/xmltv.js @@ -7,12 +7,6 @@ module.exports = { WriteXMLTV, shutdown }; let isShutdown = false; let isWorking = false; -// ──────────────────────────────────────────────────────────────────────────────── -// CONFIG -// ──────────────────────────────────────────────────────────────────────────────── -// Anything shorter than this is considered a bump / filler for merge purposes. -const CONTIG_MS = 2 * 1000; // consider ≤2‑second gaps continuous - // ──────────────────────────────────────────────────────────────────────────────── // PUBLIC API // ──────────────────────────────────────────────────────────────────────────────── @@ -44,47 +38,41 @@ function writePromise(json, xmlSettings, throttle, cacheImageService) { _writeDocStart(xw); (async () => { - // Debug logging to see what's happening console.log('XMLTV Debug: Writing XMLTV file'); - console.log('XMLTV Debug: Channel numbers:', Object.keys(json)); const channelNumbers = Object.keys(json); + console.log('XMLTV Debug: Channel numbers:', channelNumbers); + + // First write all channel elements const channels = channelNumbers.map(n => json[n].channel); _writeChannels(xw, channels); + // Then write all programs for each channel for (const number of channelNumbers) { console.log(`XMLTV Debug: Processing channel ${number}`); - console.log(`XMLTV Debug: Programs before merge: ${json[number].programs.length}`); - // Check if the programs array exists and has items + // Skip if programs array is missing or empty if (!Array.isArray(json[number].programs) || json[number].programs.length === 0) { console.error(`XMLTV Debug: ERROR - No programs array or empty array for channel ${number}`); continue; } - // Check the first few programs - console.log('XMLTV Debug: Sample programs before merge:'); - json[number].programs.slice(0, 3).forEach((prog, idx) => { - console.log(` ${idx+1}. ${prog.title || 'No title'}, start: ${prog.start || 'No start'}, stop: ${prog.stop || 'No stop'}`); - }); + console.log(`XMLTV Debug: Programs before merge: ${json[number].programs.length}`); + // Merge programs to eliminate placeholders and combine related content const merged = _smartMerge(json[number].programs); console.log(`XMLTV Debug: Programs after merge: ${merged.length}`); if (merged.length > 0) { - console.log('XMLTV Debug: Sample programs after merge:'); - merged.slice(0, 3).forEach((prog, idx) => { - console.log(` ${idx+1}. ${prog.title || 'No title'}, start: ${prog.start || 'No start'}, stop: ${prog.stop || 'No stop'}`); - }); + // Write the merged programs + await _writePrograms(xw, json[number].channel, merged, throttle, xmlSettings, cacheImageService); } else { console.error('XMLTV Debug: ERROR - No programs after merge'); } - - await _writePrograms(xw, json[number].channel, merged, throttle, xmlSettings, cacheImageService); } })() .then(() => _writeDocEnd(xw, ws)) - .catch(err => console.error('Error', err)) + .catch(err => console.error('Error in XMLTV generation:', err)) .finally(() => ws.end()); }); } @@ -100,7 +88,7 @@ function _smartMerge(programs) { // Debug log for input console.log(`_smartMerge received ${programs.length} programs`); - // Helper functions + // Helper functions for date handling function ms(t) { try { return Date.parse(t); @@ -119,9 +107,11 @@ function _smartMerge(programs) { } } + // Flex/placeholder program detection function getChannelStealthDuration(channel) { - if (channel && typeof(channel.guideMinimumDurationSeconds) !== 'undefined' - && !isNaN(channel.guideMinimumDurationSeconds)) { + if (channel && + typeof(channel.guideMinimumDurationSeconds) !== 'undefined' && + !isNaN(channel.guideMinimumDurationSeconds)) { return channel.guideMinimumDurationSeconds * 1000; } return constants.DEFAULT_GUIDE_STEALTH_DURATION; @@ -133,39 +123,34 @@ function _smartMerge(programs) { (program.duration && program.duration <= stealthDuration); } + // Program similarity detection function isSameShow(a, b) { - // Check if two programs are the same show (can be different episodes) if (!a || !b) return false; - // If we have ratingKeys, use those as a definitive check - if (a.ratingKey && b.ratingKey) { - // For exact same episode - if (a.ratingKey === b.ratingKey) return true; + // Check rating keys first (most reliable) + if (a.ratingKey && b.ratingKey && a.ratingKey === b.ratingKey) { + return true; } - // Check if it's the same show + // Check show title and type if (a.showTitle && b.showTitle && a.showTitle === b.showTitle) { - // Same show, check if sequential episodes if (a.type === 'episode' && b.type === 'episode') { + // For episodes, check if they're sequential if (a.season === b.season) { - // Same season, check if episodes are sequential - if (Math.abs((a.episode || 0) - (b.episode || 0)) <= 1) { - return true; - } + return Math.abs((a.episode || 0) - (b.episode || 0)) <= 1; } + return false; } - // Same movie or generic content + // For non-episodes with same title (movies, etc) return a.type === b.type && a.type !== 'episode'; } return false; } - // New helper function to detect exact duplicates function isExactDuplicate(a, b) { if (!a || !b) return false; - // Check for identical content return a.title === b.title && ((a.sub && b.sub && a.sub.season === b.sub.season && a.sub.episode === b.sub.episode) || (!a.sub && !b.sub)) && @@ -174,56 +159,38 @@ function _smartMerge(programs) { (a.ratingKey === b.ratingKey || (!a.ratingKey && !b.ratingKey)); } - // Get channel from first program if available + // Get channel info and define thresholds const channel = programs.length > 0 && programs[0].channel ? programs[0].channel : { guideMinimumDurationSeconds: constants.DEFAULT_GUIDE_STEALTH_DURATION / 1000, name: "dizqueTV" }; const flexTitle = channel.guideFlexPlaceholder || channel.name; - - // Threshold for considering programs adjacent const ADJACENT_THRESHOLD = 30 * 1000; // 30 seconds - - // Maximum gap for merging shows of the same title const SAME_SHOW_MAX_GAP = 10 * 60 * 1000; // 10 minutes - // Ensure all programs have the required fields and valid start/stop times + // Step 0: Filter and prepare programs let validPrograms = programs.filter(p => { - if (!p.start || !p.stop) { - return false; - } + if (!p.start || !p.stop) return false; // Ensure title exists - if (!p.title) { - p.title = p.showTitle || 'Unknown'; - } - if (!p.summary) { - p.summary = ''; - } + if (!p.title) p.title = p.showTitle || 'Unknown'; + if (!p.summary) p.summary = ''; // Validate that start is before stop - const startTime = ms(p.start); - const stopTime = ms(p.stop); - return startTime < stopTime; - }); + return ms(p.start) < ms(p.stop); + }).sort((a, b) => ms(a.start) - ms(b.start)); - // Sort by start time - validPrograms.sort((a, b) => ms(a.start) - ms(b.start)); - - // Step 1: Identify and merge blocks of content that belong together + // Step 1: Merge related content blocks and remove flex const firstPass = []; for (let i = 0; i < validPrograms.length; i++) { const prog = validPrograms[i]; - // Skip flex/placeholder programs entirely - if (isProgramFlex(prog, channel)) { - continue; - } + // Skip flex/placeholder programs + if (isProgramFlex(prog, channel)) continue; if (firstPass.length === 0) { - // First program in the list firstPass.push(prog); continue; } @@ -231,59 +198,52 @@ function _smartMerge(programs) { const lastProg = firstPass[firstPass.length - 1]; const gapDuration = gap(lastProg, prog); - // Handle overlapping or adjacent programs if (gapDuration <= ADJACENT_THRESHOLD) { - // Very small gap or overlap - if (isSameShow(lastProg, prog) || - (lastProg.title === prog.title && lastProg.type === prog.type)) { - // Merge same content + // Adjacent programs with same title - merge + if (isSameShow(lastProg, prog) || (lastProg.title === prog.title && lastProg.type === prog.type)) { lastProg.stop = prog.stop; } else { - // Different regular content - add as separate + // Different content - add separately firstPass.push(prog); } } else if (gapDuration <= SAME_SHOW_MAX_GAP && isSameShow(lastProg, prog)) { - // Small gap between segments of the same show - merge and include the gap + // Small gap between same show segments - merge lastProg.stop = prog.stop; } else { - // Significant gap or different content - add as separate + // Large gap or different content - add separately firstPass.push(prog); } } - // Step 2: Connect all programs to ensure no gaps + // Step 2: Close gaps between programs const finalResult = []; for (let i = 0; i < firstPass.length; i++) { const prog = firstPass[i]; if (i === 0) { - // First program - add as is finalResult.push(prog); continue; } const lastProg = finalResult[finalResult.length - 1]; - const gapDuration = gap(lastProg, prog); - if (gapDuration > 0) { - // There's a gap - extend the previous program to close it + // Close any gaps by extending previous program + if (gap(lastProg, prog) > 0) { lastProg.stop = prog.start; } - // Add the current program finalResult.push(prog); } - // Step 3: Handle very long segments by splitting if needed + // Step 3: Split overly long segments const splitResult = []; - for (let i = 0; i < finalResult.length; i++) { - const prog = finalResult[i]; + for (const prog of finalResult) { const duration = ms(prog.stop) - ms(prog.start); if (duration > constants.TVGUIDE_MAXIMUM_FLEX_DURATION) { - // Split long content into segments + // Split into manageable segments let currentStart = new Date(prog.start); const endTime = new Date(prog.stop); @@ -302,81 +262,69 @@ function _smartMerge(programs) { currentStart = segmentEnd; } } else { - // Not too long - add as is splitResult.push(prog); } } - // Final verification to ensure no placeholder/flex content remains + // Step 4: Final verification and cleanup + // Remove any remaining flex content for (let i = 0; i < splitResult.length; i++) { const prog = splitResult[i]; - // If somehow a flex/placeholder program made it through, remove it if (prog.title === flexTitle || isProgramFlex(prog, channel)) { console.error(`ERROR: Flex content found after processing! This should not happen.`); - // If it's not the only program, extend adjacent program(s) to cover this gap if (splitResult.length > 1) { + // Fix by extending adjacent programs if (i > 0) { - // Extend previous program to cover this gap splitResult[i - 1].stop = prog.stop; } else if (i < splitResult.length - 1) { - // Extend next program to cover this gap splitResult[i + 1].start = prog.start; } - // Remove this flex program splitResult.splice(i, 1); - i--; // Adjust index after removal + i--; } } } - // Final verification to check for any gaps + // Fix any remaining gaps for (let i = 1; i < splitResult.length; i++) { const prevStop = ms(splitResult[i-1].stop); const currStart = ms(splitResult[i].start); - if (currStart - prevStop > 1000) { // 1 second threshold - console.error(`ERROR: Gap detected after final processing: ${new Date(prevStop).toISOString()} - ${new Date(currStart).toISOString()}`); - - // Fix the gap + if (currStart - prevStop > 1000) { + console.error(`ERROR: Gap detected after processing: ${new Date(prevStop).toISOString()} - ${new Date(currStart).toISOString()}`); splitResult[i-1].stop = splitResult[i].start; } } - // Step 4: Merge consecutive identical programs (final pass) + // Step 5: Merge consecutive identical programs console.log('XMLTV Debug: Starting final pass to merge identical consecutive programs'); const deduplicatedResult = []; - let currentGroup = null; let lastProgram = null; - for (let i = 0; i < splitResult.length; i++) { - const prog = splitResult[i]; - + for (const prog of splitResult) { if (lastProgram === null) { - // First program - lastProgram = prog; deduplicatedResult.push(prog); + lastProgram = prog; continue; } - // Check if this is an exact duplicate of the last program + // Merge exact duplicates if (isExactDuplicate(lastProgram, prog) && Math.abs(ms(lastProgram.stop) - ms(prog.start)) <= ADJACENT_THRESHOLD) { - // Merge by extending the last program console.log(`XMLTV Debug: Merging duplicate program: ${prog.title}`); lastProgram.stop = prog.stop; } else { - // Different program, add as new deduplicatedResult.push(prog); lastProgram = prog; } } console.log(`XMLTV Debug: Final pass reduced from ${splitResult.length} to ${deduplicatedResult.length} programs`); - console.log(`_smartMerge returning ${deduplicatedResult.length} programs (from original ${programs.length})`); + return deduplicatedResult; } @@ -412,61 +360,72 @@ function _writeChannels(xw, channels) { } async function _writePrograms(xw, channel, programs, throttle, xmlSettings, cacheImageService) { + // Log the number of programs to be written + console.log(`Writing ${programs.length} programs for channel ${channel.number}`); + + // Process each program with throttling for (const prog of programs) { - if (!isShutdown) await throttle(); + if (isShutdown) break; // Early exit if shutdown requested + await throttle(); await _writeProgramme(channel, prog, xw, xmlSettings, cacheImageService); } } async function _writeProgramme(channel, prog, xw, xmlSettings, cacheImageService) { try { - // Debug log to identify issues - console.log(`Writing program: ${prog.title}, start: ${prog.start}, stop: ${prog.stop}`); - - // Validate that we have valid ISO date strings + // Validate program data if (!prog.start || !prog.stop) { - console.error('ERROR: Program missing start or stop time:', prog); - return; // Skip this program + console.error('ERROR: Program missing start or stop time:', prog.title || 'Unknown'); + return; // Skip invalid program } - // Validate that the dates can be formatted + // Format dates and validate + let startDate, stopDate; try { - const startDate = _xmltvDate(prog.start); - const stopDate = _xmltvDate(prog.stop); - console.log(`Formatted dates: start=${startDate}, stop=${stopDate}`); + startDate = _xmltvDate(prog.start); + stopDate = _xmltvDate(prog.stop); } catch (e) { - console.error('ERROR: Failed to format dates:', e); - return; // Skip this program + console.error('ERROR: Invalid date format in program:', prog.title, e.message); + return; // Skip program with invalid dates } + // Write program element and attributes xw.startElement('programme'); - xw.writeAttribute('start', _xmltvDate(prog.start)); - xw.writeAttribute('stop', _xmltvDate(prog.stop)); + xw.writeAttribute('start', startDate); + xw.writeAttribute('stop', stopDate); xw.writeAttribute('channel', channel.number); + // Write title xw.startElement('title'); xw.writeAttribute('lang', 'en'); xw.text(prog.title); xw.endElement(); + + // Add previously-shown tag xw.writeRaw('\n '); + // Add episode information if available if (prog.sub) { + // Subtitle (episode title) xw.startElement('sub-title'); xw.writeAttribute('lang', 'en'); xw.text(prog.sub.title); xw.endElement(); + // Episode numbering in human-readable format xw.startElement('episode-num'); xw.writeAttribute('system', 'onscreen'); xw.text(`S${prog.sub.season} E${prog.sub.episode}`); xw.endElement(); + // Episode numbering in XMLTV standard format (zero-based) xw.startElement('episode-num'); xw.writeAttribute('system', 'xmltv_ns'); xw.text(`${prog.sub.season - 1}.${prog.sub.episode - 1}.0/1`); xw.endElement(); } + // Add program icon/thumbnail if available if (prog.icon) { xw.startElement('icon'); let icon = prog.icon; @@ -477,11 +436,13 @@ async function _writeProgramme(channel, prog, xw, xmlSettings, cacheImageService xw.endElement(); } + // Add program description xw.startElement('desc'); xw.writeAttribute('lang', 'en'); xw.text(prog.summary && prog.summary.length > 0 ? prog.summary : channel.name); xw.endElement(); + // Add content rating if available if (prog.rating) { xw.startElement('rating'); xw.writeAttribute('system', 'MPAA'); @@ -489,9 +450,9 @@ async function _writeProgramme(channel, prog, xw, xmlSettings, cacheImageService xw.endElement(); } - xw.endElement(); + xw.endElement(); // Close programme element } catch (error) { - console.error('Error writing program:', error, prog); + console.error('Error writing program:', prog.title || 'Unknown', error.message); } }