commit c12f0945a417a4cae927c4402c2fc76f41ebfd97 Author: Dongho Kim Date: Mon Jun 22 02:33:30 2026 +0200 update diff --git a/.env b/.env new file mode 100644 index 0000000..10abb01 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +PORT=3000 + +# Format: CHANNELS=id1:Name1,id2:Name2 +# You can find the channel ID in the URL of the streamer's Chzzk channel (e.g., chzzk.naver.com/CHANNEL_ID) +CHANNELS=4df4756104a54e28e967bff6dc08e319:KBS2,8ecd602c251f30fd7f09463e9f55609f:JTBC diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fb16184 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f867ad4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: '3.8' + +services: + chzzk-proxy: + build: . + container_name: chzzk-jellyfin-proxy + ports: + - "3000:3000" + env_file: + - .env + restart: unless-stopped diff --git a/index.js b/index.js new file mode 100644 index 0000000..0d9c458 --- /dev/null +++ b/index.js @@ -0,0 +1,72 @@ +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 response = await axios.get(`https://api.chzzk.naver.com/service/v1/channels/${channelId}/live-detail`, { + 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 data = response.data; + if (!data || !data.content || !data.content.livePlaybackJson) { + return res.status(404).send('Stream not found or offline'); + } + + const livePlayback = JSON.parse(data.content.livePlaybackJson); + 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 the actual M3U8 url + res.redirect(302, 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.listen(port, () => { + console.log(`Chzzk IPTV Proxy running on port ${port}`); + console.log(`Playlist URL: http://localhost:${port}/playlist.m3u`); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..e5ef010 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "czzk-jellyfin-proxy", + "version": "1.0.0", + "description": "Chzzk to Jellyfin IPTV Proxy", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "axios": "^1.6.8", + "dotenv": "^16.4.5", + "express": "^4.19.2" + } +}