require('dotenv').config(); const express = require('express'); const axios = require('axios'); const app = express(); const port = process.env.PORT || 3000; // 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); }; 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); }); app.get('/stream/:channelId', async (req, res) => { const { channelId } = req.params; try { 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('; '); } const response = await axios.get(`https://api.chzzk.naver.com/service/v3/channels/${channelId}/live-detail`, { headers }); const data = response.data; const content = data && data.content; 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 intercept keys 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}`; // 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; }); res.header('Content-Type', 'application/vnd.apple.mpegurl'); res.send(processedLines.join('\n')); } 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 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('; '); } const response = await axios.get(keyUrl, { headers, 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`); });