diff --git a/index.js b/index.js index dd72e51..2b7ec31 100644 --- a/index.js +++ b/index.js @@ -210,6 +210,128 @@ app.get('/epg.xml', async (req, res) => { res.send(xml); }); +// Helper: parse, filter, and verify playability of variants from the master playlist. +// Keeps the stream alive even if 1080p (or other qualities) return 403 Forbidden due to cookie issues. +const filterMasterPlaylist = async (m3u8Data, m3u8Url) => { + try { + const lines = m3u8Data.split('\n'); + const variants = []; + let currentHeaders = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + if (line.startsWith('#')) { + if (line.startsWith('#EXT-X-STREAM-INF')) { + currentHeaders.push(line); + } else if (line.startsWith('#EXTM3U') || line.startsWith('#EXT-X-VERSION') || line.startsWith('#EXT-X-INDEPENDENT-SEGMENTS')) { + // Skip global headers as we add them manually + } else { + currentHeaders.push(line); + } + } else { + let uri = line; + if (!uri.startsWith('http')) { + uri = new URL(uri, m3u8Url).toString(); + } + variants.push({ + headers: currentHeaders, + uri: uri + }); + currentHeaders = []; + } + } + + if (variants.length === 0) return m3u8Data; + + // Test playability of each variant in parallel + const checkedVariants = await Promise.all( + variants.map(async (v) => { + try { + await axios.get(v.uri, { + headers: buildHeaders(), + timeout: 1500, + responseType: 'stream' + }); + return { ...v, playable: true }; + } catch (err) { + console.log(`[Proxy] Quality variant not playable: ${v.uri} (status: ${err.response?.status || 'error'})`); + return { ...v, playable: false }; + } + }) + ); + + const playableVariants = checkedVariants.filter(v => v.playable); + const finalVariants = playableVariants.length > 0 ? playableVariants : variants; + + let output = '#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-INDEPENDENT-SEGMENTS\n'; + finalVariants.forEach(v => { + v.headers.forEach(h => { + output += h + '\n'; + }); + output += v.uri + '\n'; + }); + + return output; + } catch (error) { + console.error('Error filtering master playlist:', error.message); + return m3u8Data; + } +}; + +// Helper: processes an M3U8 playlist by converting any relative URLs to absolute, +// proxying sub-playlists (.m3u8), and proxying decryption keys. +const processM3U8 = (m3u8Data, m3u8Url, baseUrl) => { + const lines = m3u8Data.split('\n'); + const processedLines = lines.map(line => { + let processed = line.trim(); + if (!processed) return processed; + + // 1. Rewrite AES Key URIs to use our proxy (making them absolute first if needed) + if (processed.startsWith('#EXT-X-KEY:')) { + processed = processed.replace(/URI="([^"]+)"/, (match, keyUrl) => { + let absoluteKeyUrl = keyUrl; + if (!keyUrl.startsWith('http')) { + absoluteKeyUrl = new URL(keyUrl, m3u8Url).toString(); + } + return `URI="${baseUrl}/key?url=${encodeURIComponent(absoluteKeyUrl)}"`; + }); + return processed; + } + + // 2. Rewrite other tag URIs (like #EXT-X-MEDIA, #EXT-X-MAP) to be absolute + if (processed.startsWith('#') && processed.includes('URI=')) { + processed = processed.replace(/URI="([^"]+)"/g, (match, uri) => { + if (uri && !uri.startsWith('http')) { + uri = new URL(uri, m3u8Url).toString(); + } + if (uri.includes('.m3u8') && !uri.includes('key?url=')) { + uri = `${baseUrl}/m3u8?url=${encodeURIComponent(uri)}`; + } + return `URI="${uri}"`; + }); + return processed; + } + + // 3. Rewrite main segment/variant playlist URLs to be absolute (and proxy variant playlists) + if (!processed.startsWith('#')) { + let uri = processed; + if (!uri.startsWith('http')) { + uri = new URL(uri, m3u8Url).toString(); + } + if (uri.includes('.m3u8')) { + uri = `${baseUrl}/m3u8?url=${encodeURIComponent(uri)}`; + } + return uri; + } + + return processed; + }); + + return processedLines.join('\n'); +}; + app.get('/stream/:channelId', async (req, res) => { const { channelId } = req.params; @@ -228,8 +350,6 @@ app.get('/stream/:channelId', async (req, res) => { const hlsMedia = media.find(m => m.mediaId === 'HLS' || m.mediaId === 'LLHLS'); if (hlsMedia && hlsMedia.path) { - // Serve HLS master playlist directly (no redirect) so ffmpeg/Jellyfin - // doesn't need to follow a redirect chain which can cause code 8 errors const host = req.get('host'); const protocol = req.protocol; const baseUrl = `${protocol}://${host}`; @@ -238,34 +358,13 @@ app.get('/stream/:channelId', async (req, res) => { const m3u8Res = await axios.get(m3u8Url); let m3u8Data = m3u8Res.data; - const lines = m3u8Data.split('\n'); - const processedLines = lines.map(line => { - let processed = line.trim(); - if (!processed) return processed; + // Filter out unplayable variants (e.g. 1080p returning 403) before returning the playlist to Jellyfin + m3u8Data = await filterMasterPlaylist(m3u8Data, m3u8Url); - if (processed.startsWith('#EXT-X-KEY:')) { - processed = processed.replace(/URI="(https?:\/\/[^"]+)"/, (match, keyUrl) => { - return `URI="${baseUrl}/key?url=${encodeURIComponent(keyUrl)}"`; - }); - return processed; - } - - if (!processed.startsWith('#')) { - let uri = processed; - if (!uri.startsWith('http')) { - uri = new URL(uri, m3u8Url).toString(); - } - if (uri.includes('.m3u8')) { - uri = `${baseUrl}/m3u8?url=${encodeURIComponent(uri)}`; - } - return uri; - } - - return processed; - }); + const processedM3U8 = processM3U8(m3u8Data, m3u8Url, baseUrl); res.header('Content-Type', 'application/vnd.apple.mpegurl'); - res.send(processedLines.join('\n')); + res.send(processedM3U8); } else { res.status(404).send('HLS stream not found in playback info'); } @@ -286,52 +385,10 @@ app.get('/m3u8', async (req, res) => { const protocol = req.protocol; const baseUrl = `${protocol}://${host}`; - // Process m3u8 lines - const lines = m3u8Data.split('\n'); - const processedLines = lines.map(line => { - let processed = line.trim(); - if (!processed) return processed; - - // Rewrite AES Key URIs to use our proxy - if (processed.startsWith('#EXT-X-KEY:')) { - processed = processed.replace(/URI="(https?:\/\/[^"]+)"/, (match, keyUrl) => { - return `URI="${baseUrl}/key?url=${encodeURIComponent(keyUrl)}"`; - }); - return processed; - } - - // If it's a tag with a URI (like #EXT-X-MEDIA:URI="..."), make it absolute and proxy it if it's an m3u8 - if (processed.startsWith('#EXT-X-MEDIA:') && processed.includes('URI=')) { - processed = processed.replace(/URI="(.*?)"/, (match, uri) => { - if (uri && !uri.startsWith('http')) { - uri = new URL(uri, m3u8Url).toString(); - } - if (uri.includes('.m3u8')) { - uri = `${baseUrl}/m3u8?url=${encodeURIComponent(uri)}`; - } - return `URI="${uri}"`; - }); - return processed; - } - - // If it's a URL (variant playlist or segment) - if (!processed.startsWith('#')) { - let uri = processed; - if (!uri.startsWith('http')) { - uri = new URL(uri, m3u8Url).toString(); - } - // If it's a variant playlist, proxy it - if (uri.includes('.m3u8')) { - uri = `${baseUrl}/m3u8?url=${encodeURIComponent(uri)}`; - } - return uri; - } - - return processed; - }); + const processedM3U8 = processM3U8(m3u8Data, m3u8Url, baseUrl); res.header('Content-Type', 'application/vnd.apple.mpegurl'); - res.send(processedLines.join('\n')); + res.send(processedM3U8); } catch (error) { console.error(`Error proxying m3u8: ${error.message}`); res.status(500).send('Error proxying m3u8');