update
This commit is contained in:
@@ -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, '"').replace(/'/g, ''');
|
||||
};
|
||||
|
||||
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 — 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 += ` <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) {
|
||||
const iconUrl = content.liveImageUrl.replace('{type}', '720');
|
||||
xml += ` <icon src="${escapeXml(iconUrl)}" />\n`;
|
||||
}
|
||||
xml += ` </programme>\n`;
|
||||
} else {
|
||||
// Offline — fill today with an "Off Air" block
|
||||
xml += ` <programme start="${toXmltvDate(dayStart)}" stop="${toXmltvDate(dayEnd)}" channel="${escapeXml(ch.id)}">\n`;
|
||||
xml += ` <title>Off Air</title>\n`;
|
||||
xml += ` <desc>${escapeXml(ch.name)} is not currently live.</desc>\n`;
|
||||
xml += ` </programme>\n`;
|
||||
}
|
||||
});
|
||||
|
||||
xml += '</tv>\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`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user