diff --git a/index.js b/index.js index 7f09e77..680790f 100644 --- a/index.js +++ b/index.js @@ -33,27 +33,137 @@ app.get('/playlist.m3u', (req, res) => { res.send(m3u); }); +// 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; +}; + +// 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 for live channels + results.forEach(r => { + if (r.status !== 'fulfilled') return; + const { ch, content } = r.value; + if (!content || content.status !== 'OPEN') return; + + const openDate = content.openDate + ? content.openDate.replace(/[-: ]/g, '').substring(0, 14) + ' +0900' + : null; + // Assume a 6-hour window for the programme end (no real end time available) + const endDate = content.openDate + ? (() => { + const d = new Date(content.openDate); + d.setHours(d.getHours() + 6); + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())} +0900`; + })() + : null; + + if (openDate && endDate) { + xml += ` \n`; + xml += ` ${escapeXml(content.liveTitle || ch.name)}\n`; + if (content.liveCategoryValue || content.liveCategory) { + xml += ` ${escapeXml(content.liveCategoryValue || content.liveCategory)}\n`; + } + if (content.liveImageUrl) { + xml += ` \n`; + } + xml += ` \n`; + } + }); + + xml += '\n'; + res.header('Content-Type', 'application/xml'); + res.send(xml); +}); + 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) { @@ -148,18 +258,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) { @@ -171,4 +270,6 @@ 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(`Schedule URL: http://localhost:${port}/schedule`); + console.log(`EPG URL: http://localhost:${port}/epg.xml`); });