require('dotenv').config(); const express = require('express'); const axios = require('axios'); const app = express(); const port = process.env.PORT || 3000; // Log all incoming requests app.use((req, res, next) => { console.log(`[Proxy Log] ${new Date().toISOString()} - ${req.method} ${req.url} - IP: ${req.ip}`); next(); }); // Parse channels from .env // Expected format: CHANNELS=id1:Name1,id2:Name2 const parseChannels = () => { const channelsString = process.env.CHANNELS || ''; if (!channelsString) return []; return channelsString.split(',').map(ch => { const [id, ...nameParts] = ch.split(':'); return { id: id.trim(), name: nameParts.join(':').trim() }; }).filter(ch => ch.id && ch.name); }; // Helper: build request headers with optional Naver auth cookies const buildHeaders = () => { const headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }; const cookies = []; if (process.env.NID_AUT) cookies.push(`NID_AUT=${process.env.NID_AUT}`); if (process.env.NID_SES) cookies.push(`NID_SES=${process.env.NID_SES}`); if (cookies.length > 0) { headers['Cookie'] = cookies.join('; '); } return headers; }; // Helper: fetch live-detail for a single channel const fetchLiveDetail = async (channelId) => { const response = await axios.get( `https://api.chzzk.naver.com/service/v3/channels/${channelId}/live-detail`, { headers: buildHeaders() } ); return response.data && response.data.content; }; // Static playlist — always contains all channels app.get('/playlist.m3u', (req, res) => { const channels = parseChannels(); const host = req.get('host'); const protocol = req.protocol; let m3u = "#EXTM3U\n"; channels.forEach(ch => { m3u += `#EXTINF:-1 tvg-id="${ch.id}" tvg-name="${ch.name}" group-title="Chzzk",${ch.name}\n`; m3u += `${protocol}://${host}/stream/${ch.id}\n`; }); res.header('Content-Type', 'application/vnd.apple.mpegurl'); res.send(m3u); }); // Dynamic live-only playlist — only includes channels currently broadcasting. // To prevent Jellyfin from throwing a "Save TV provider" error when no channels are live, // we serve a placeholder channel "No Live Streams" if the list is empty. app.get('/playlist-live.m3u', async (req, res) => { const channels = parseChannels(); const host = req.get('host'); const protocol = req.protocol; const results = await Promise.allSettled( channels.map(async (ch) => { try { const content = await fetchLiveDetail(ch.id); return { ch, content }; } catch { return { ch, content: null }; } }) ); let m3u = "#EXTM3U\n"; let liveCount = 0; results.forEach(r => { if (r.status !== 'fulfilled') return; const { ch, content } = r.value; // Only include the channel if it is actively live (OPEN status) if (!content || content.status !== 'OPEN') return; const title = content.liveTitle ? `${ch.name} — ${content.liveTitle}` : ch.name; const logo = (content.channel && content.channel.channelImageUrl) || ''; m3u += `#EXTINF:-1 tvg-id="${ch.id}" tvg-name="${ch.name}" tvg-logo="${logo}" group-title="Chzzk Live",${title}\n`; m3u += `${protocol}://${host}/stream/${ch.id}\n`; liveCount++; }); if (liveCount === 0) { m3u += `#EXTINF:-1 tvg-id="offline" tvg-name="No Live Streams" group-title="Chzzk Live",No Live Streams\n`; m3u += `${protocol}://${host}/offline\n`; } res.header('Content-Type', 'application/vnd.apple.mpegurl'); res.send(m3u); }); app.get('/offline', (req, res) => { res.status(404).send('No streams are currently live.'); }); // Schedule endpoint — returns live status for all configured channels as JSON app.get('/schedule', async (req, res) => { const channels = parseChannels(); if (channels.length === 0) { return res.json({ channels: [], fetchedAt: new Date().toISOString() }); } const results = await Promise.allSettled( channels.map(async (ch) => { try { const content = await fetchLiveDetail(ch.id); if (!content) { return { id: ch.id, name: ch.name, status: 'OFFLINE' }; } return { id: ch.id, name: ch.name, status: content.status || 'UNKNOWN', liveTitle: content.liveTitle || null, liveCategory: content.liveCategoryValue || content.liveCategory || null, openDate: content.openDate || null, concurrentUserCount: content.concurrentUserCount || 0, channelImageUrl: content.channel && content.channel.channelImageUrl || null, liveImageUrl: content.liveImageUrl || null, }; } catch { return { id: ch.id, name: ch.name, status: 'ERROR' }; } }) ); const schedule = results.map(r => r.status === 'fulfilled' ? r.value : r.reason); res.json({ channels: schedule, fetchedAt: new Date().toISOString() }); }); // EPG endpoint — generates XMLTV-format guide data for Jellyfin app.get('/epg.xml', async (req, res) => { const channels = parseChannels(); const results = await Promise.allSettled( channels.map(async (ch) => { try { const content = await fetchLiveDetail(ch.id); return { ch, content }; } catch { return { ch, content: null }; } }) ); const escapeXml = (str) => { if (!str) return ''; return str.replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); }; let xml = '\n'; xml += '\n'; xml += '\n'; // Channel definitions channels.forEach(ch => { xml += ` \n`; xml += ` ${escapeXml(ch.name)}\n`; xml += ` \n`; }); // Programme entries — live channels show stream info, offline show "Off Air" const now = new Date(); const pad = n => String(n).padStart(2, '0'); const toXmltvDate = (d) => `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())} +0000`; // Start of today (UTC) and end of today for Off Air block const dayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); results.forEach(r => { if (r.status !== 'fulfilled') return; const { ch, content } = r.value; const isLive = content && content.status === 'OPEN'; if (isLive) { const openDate = content.openDate ? content.openDate.replace(/[-: ]/g, '').substring(0, 14) + ' +0900' : toXmltvDate(now); const endDate = content.openDate ? (() => { const d = new Date(content.openDate); d.setHours(d.getHours() + 6); return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())} +0900`; })() : toXmltvDate(dayEnd); xml += ` \n`; xml += ` ${escapeXml(content.liveTitle || ch.name)}\n`; if (content.liveCategoryValue || content.liveCategory) { xml += ` ${escapeXml(content.liveCategoryValue || content.liveCategory)}\n`; } if (content.liveImageUrl) { const iconUrl = content.liveImageUrl.replace('{type}', '720'); xml += ` \n`; } xml += ` \n`; } else { // Offline — fill today with an "Off Air" block xml += ` \n`; xml += ` Off Air\n`; xml += ` ${escapeXml(ch.name)} is not currently live.\n`; xml += ` \n`; } }); xml += '\n'; res.header('Content-Type', 'application/xml'); 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; try { const content = await fetchLiveDetail(channelId); const playbackJsonString = content && (content.livePlaybackJson || content.previewPlaybackJson); if (!playbackJsonString) { return res.status(404).send('Stream not found, offline, or region blocked'); } const livePlayback = JSON.parse(playbackJsonString); const media = livePlayback.media || []; // Find HLS or LLHLS stream const hlsMedia = media.find(m => m.mediaId === 'HLS' || m.mediaId === 'LLHLS'); if (hlsMedia && hlsMedia.path) { // Redirect Jellyfin to our proxy endpoint to ensure ffprobe/ffmpeg detects the HLS format correctly const host = req.get('host'); const protocol = req.protocol; res.redirect(302, `${protocol}://${host}/m3u8?url=${encodeURIComponent(hlsMedia.path)}`); } else { res.status(404).send('HLS stream not found in playback info'); } } catch (error) { console.error(`Error fetching stream for channel ${channelId}:`, error.message); res.status(500).send('Error fetching stream from Chzzk API'); } }); app.get('/m3u8', async (req, res) => { const m3u8Url = req.query.url; if (!m3u8Url) return res.status(400).send('Missing url parameter'); try { const response = await axios.get(m3u8Url); let m3u8Data = response.data; const host = req.get('host'); const protocol = req.protocol; const baseUrl = `${protocol}://${host}`; // If it's a master playlist, dynamically check and filter out unplayable variants if (m3u8Data.includes('#EXT-X-STREAM-INF')) { m3u8Data = await filterMasterPlaylist(m3u8Data, m3u8Url); } const processedM3U8 = processM3U8(m3u8Data, m3u8Url, baseUrl); res.header('Content-Type', 'application/vnd.apple.mpegurl'); res.send(processedM3U8); } catch (error) { console.error(`Error proxying m3u8: ${error.message}`); res.status(500).send('Error proxying m3u8'); } }); app.get('/key', async (req, res) => { const keyUrl = req.query.url; if (!keyUrl) return res.status(400).send('Missing url parameter'); try { const response = await axios.get(keyUrl, { headers: buildHeaders(), responseType: 'arraybuffer' }); res.header('Content-Type', 'application/octet-stream'); res.send(response.data); } catch (error) { console.error(`Error fetching key: ${error.message}`); res.status(500).send('Error fetching key'); } }); app.listen(port, () => { console.log(`Chzzk IPTV Proxy running on port ${port}`); console.log(`Playlist URL: http://localhost:${port}/playlist.m3u`); console.log(`Live Playlist URL: http://localhost:${port}/playlist-live.m3u`); console.log(`Schedule URL: http://localhost:${port}/schedule`); console.log(`EPG URL: http://localhost:${port}/epg.xml`); });