175 lines
6.4 KiB
JavaScript
175 lines
6.4 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);
|
|
});
|
|
|
|
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`);
|
|
});
|