diff --git a/index.js b/index.js index 7f09e77..363ca66 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,12 @@ 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 = () => { @@ -17,6 +23,30 @@ const parseChannels = () => { }).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'); @@ -33,27 +63,299 @@ app.get('/playlist.m3u', (req, res) => { 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 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 content = await fetchLiveDetail(channelId); const playbackJsonString = content && (content.livePlaybackJson || content.previewPlaybackJson); if (!playbackJsonString) { @@ -67,7 +369,7 @@ app.get('/stream/:channelId', async (req, res) => { const hlsMedia = media.find(m => m.mediaId === 'HLS' || m.mediaId === 'LLHLS'); if (hlsMedia && hlsMedia.path) { - // Redirect Jellyfin to our proxy endpoint to intercept keys + // 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)}`); @@ -91,52 +393,15 @@ 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; + // 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); + } - // 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'); @@ -148,18 +413,7 @@ app.get('/key', async (req, res) => { 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' }); + const response = await axios.get(keyUrl, { headers: buildHeaders(), responseType: 'arraybuffer' }); res.header('Content-Type', 'application/octet-stream'); res.send(response.data); } catch (error) { @@ -170,5 +424,8 @@ app.get('/key', async (req, res) => { app.listen(port, () => { console.log(`Chzzk IPTV Proxy running on port ${port}`); - console.log(`Playlist URL: http://localhost:${port}/playlist.m3u`); + 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`); });