Files
czzk-stream/index.js
T
Dongho Kim f58f17eb5d update
2026-06-22 15:05:56 +02:00

314 lines
12 KiB
JavaScript

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);
});
// Dynamic live-only playlist — only includes channels currently broadcasting
// This is the URL you point Threadfin at
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";
results.forEach(r => {
if (r.status !== 'fulfilled') return;
const { ch, content } = r.value;
// Only include the channel if it is actively live
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`;
});
res.header('Content-Type', 'application/vnd.apple.mpegurl');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&apos;');
};
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<!DOCTYPE tv SYSTEM "xmltv.dtd">\n';
xml += '<tv generator-info-name="czzk-stream">\n';
// Channel definitions
channels.forEach(ch => {
xml += ` <channel id="${escapeXml(ch.id)}">\n`;
xml += ` <display-name>${escapeXml(ch.name)}</display-name>\n`;
xml += ` </channel>\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 += ` <programme start="${openDate}" stop="${endDate}" channel="${escapeXml(ch.id)}">\n`;
xml += ` <title>${escapeXml(content.liveTitle || ch.name)}</title>\n`;
if (content.liveCategoryValue || content.liveCategory) {
xml += ` <category>${escapeXml(content.liveCategoryValue || content.liveCategory)}</category>\n`;
}
if (content.liveImageUrl) {
xml += ` <icon src="${escapeXml(content.liveImageUrl)}" />\n`;
}
xml += ` </programme>\n`;
}
});
xml += '</tv>\n';
res.header('Content-Type', 'application/xml');
res.send(xml);
});
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 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 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`);
});