Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Pluto for Channels is a Docker container that fetches channel data from Pluto TV's API and generates M3U playlists and XMLTV EPG files optimized for use with [Channels DVR](https://getchannels.com). It runs nginx to serve the generated files over HTTP.

## Authentication

Pluto TV requires authentication for streams to work. The converter authenticates via `boot.pluto.tv/v4/start` to obtain a session token (JWT) and stitcher params. These are used to construct authenticated stream URLs.

- **Token validity:** 24 hours
- **Refresh cycle:** Every 3 hours (in Docker entrypoint)

## Build and Run Commands

```bash
# Build Docker image locally
docker build -t pluto-for-channels .

# Run container (credentials required)
docker run -d --restart unless-stopped --name pluto-for-channels -p 8080:80 \
-e PLUTO_USERNAME='your@email.com' \
-e PLUTO_PASSWORD='yourpassword' \
jonmaddox/pluto-for-channels

# Run with multiple feed versions
docker run -d -p 8080:80 \
-e PLUTO_USERNAME='your@email.com' \
-e PLUTO_PASSWORD='yourpassword' \
-e VERSIONS=Dad,Bob,Joe \
jonmaddox/pluto-for-channels

# Run with custom starting channel number
docker run -d -p 8080:80 \
-e PLUTO_USERNAME='your@email.com' \
-e PLUTO_PASSWORD='yourpassword' \
-e START=80000 \
jonmaddox/pluto-for-channels
```

## Local Development

```bash
# Install dependencies
cd PlutoIPTV && yarn install

# Set up credentials
cp .env.example .env
# Edit .env with your Pluto TV credentials

# Run the converter locally
./run.sh

# Or run with versions
./run.sh Dad,Bob,Joe
```

## Architecture

- **`PlutoIPTV/index.js`** - Main converter that authenticates with Pluto TV, fetches channel data via their API, generates M3U8 playlists and XMLTV EPG files. Handles caching (30 min), retries, and genre mapping.
- **`PlutoIPTV/run.sh`** - Local development script that loads `.env` and runs the converter.
- **`entrypoint.sh`** - Container entrypoint that runs nginx, executes the converter every 3 hours, and updates the status page with links to generated files.
- **`index.html`** - Template for the status page served at the container's root URL.
- **`Dockerfile`** - Alpine-based nginx image with Node.js for running the converter.
- **`VERSION`** - Contains the current version number (used for update notifications).

## Environment Variables

- `PLUTO_USERNAME` - **(Required)** Pluto TV account email
- `PLUTO_PASSWORD` - **(Required)** Pluto TV account password
- `VERSIONS` - Comma-separated list of feed names to generate (creates separate playlist/EPG files per version)
- `START` - Starting channel number offset (e.g., 80000 makes channel 345 become 80345)

## Output Files

- `playlist.m3u` / `{version}-playlist.m3u` - M3U playlist with Channels-specific extensions (contains authenticated stream URLs with JWT)
- `epg.xml` / `{version}-epg.xml` - XMLTV format EPG data

## CI/CD

GitHub Actions workflow (`.github/workflows/docker-publish.yml`) builds and pushes multi-architecture Docker images to Docker Hub on every push to main.
3 changes: 3 additions & 0 deletions PlutoIPTV/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
PLUTO_USERNAME=your@email.com
PLUTO_PASSWORD=yourpassword
START=0
1 change: 1 addition & 0 deletions PlutoIPTV/.gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/*
cache.json
.env
*playlist.m3u8
*playlist.m3u
*epg.xml
Expand Down
105 changes: 74 additions & 31 deletions PlutoIPTV/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,65 @@ const uuid4 = require("uuid").v4;
const uuid1 = require("uuid").v1;
const url = require("url");

// Authentication data stored after calling authenticate()
let authData = null;

// Authenticate with Pluto TV to get session token and stitcher params
function authenticate() {
return new Promise((resolve, reject) => {
// Validate required credentials
if (!process.env.PLUTO_USERNAME || !process.env.PLUTO_PASSWORD) {
reject(new Error('PLUTO_USERNAME and PLUTO_PASSWORD environment variables are required'));
return;
}

const deviceId = uuid1();
const bootParams = new URLSearchParams({
appName: 'web',
appVersion: '8.0.0-111b2b9dc00bd0bea9030b30662159ed9e7c8bc6',
deviceVersion: '122.0.0',
deviceModel: 'web',
deviceMake: 'chrome',
deviceType: 'web',
clientID: deviceId,
clientModelNumber: '1.0.0',
serverSideAds: 'false',
drmCapabilities: 'widevine:L3',
username: process.env.PLUTO_USERNAME,
password: process.env.PLUTO_PASSWORD,
});

const bootUrl = `https://boot.pluto.tv/v4/start?${bootParams.toString()}`;
console.log('[INFO] Authenticating with Pluto TV...');

request(bootUrl, function (err, response, body) {
if (err) {
reject(new Error(`Authentication request failed: ${err.message}`));
return;
}

try {
const data = JSON.parse(body);

if (!data.sessionToken) {
reject(new Error('Authentication failed: No session token in response'));
return;
}

authData = {
sessionToken: data.sessionToken,
stitcherParams: data.stitcherParams || '',
};

console.log('[INFO] Authentication successful');
resolve(authData);
} catch (parseErr) {
reject(new Error(`Failed to parse authentication response: ${parseErr.message}`));
}
});
});
}

const conflictingChannels = [
"cnn",
"dabl",
Expand Down Expand Up @@ -347,37 +406,13 @@ function processChannels(version, list) {

let m3u8 = "#EXTM3U\n\n";
channels.forEach((channel) => {
let deviceId = uuid1();
let sid = uuid4();
if (
channel.isStitched &&
!channel.slug.match(/^announcement|^privacy-policy/)
) {
let m3uUrl = new URL(channel.stitched.urls[0].url);
let queryString = url.search;
let params = new URLSearchParams(queryString);

// set the url params
params.set("advertisingId", "");
params.set("appName", "web");
params.set("appVersion", "unknown");
params.set("appStoreUrl", "");
params.set("architecture", "");
params.set("buildVersion", "");
params.set("clientTime", "0");
params.set("deviceDNT", "0");
params.set("deviceId", deviceId);
params.set("deviceMake", "Chrome");
params.set("deviceModel", "web");
params.set("deviceType", "web");
params.set("deviceVersion", "unknown");
params.set("includeExtendedEvents", "false");
params.set("sid", sid);
params.set("userId", "");
params.set("serverSideAds", "true");

m3uUrl.search = params.toString();
m3uUrl = m3uUrl.toString();
// Construct authenticated stream URL
const stitcher = "https://cfd-v4-service-channel-stitcher-use1-1.prd.pluto.tv";
let m3uUrl = `${stitcher}/v2/stitch/hls/channel/${channel._id}/master.m3u8?${authData.stitcherParams}&jwt=${authData.sessionToken}&masterJWTPassthrough=true&includeExtendedEvents=true`;

let slug = conflictingChannels.includes(channel.slug)
? `pluto-${channel.slug}`
Expand Down Expand Up @@ -634,8 +669,16 @@ ${m3uUrl}
console.log(`[SUCCESS] Wrote the M3U8 tuner to ${playlistFileName}!`);
}

versions.forEach((version) => {
plutoIPTV.grabJSON(function (channels) {
processChannels(version, channels);
// Main execution - authenticate first, then process channels
authenticate()
.then(() => {
versions.forEach((version) => {
plutoIPTV.grabJSON(function (channels) {
processChannels(version, channels);
});
});
})
.catch((err) => {
console.error('[ERROR] ' + err.message);
process.exit(1);
});
});
14 changes: 14 additions & 0 deletions PlutoIPTV/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/bin/bash

# Load .env file if it exists
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
else
echo "Error: .env file not found"
echo "Copy .env.example to .env and fill in your credentials:"
echo " cp .env.example .env"
exit 1
fi

# Run the converter
node index.js "$@"
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,21 @@ This simple Docker image will generate an M3U playlist and EPG optimized for use

## Set Up

Running the container is easy. Fire up the container as usual. You can set which port it runs on.
Pluto TV now requires authentication for streams to work. You'll need a free Pluto TV account.

docker run -d --restart unless-stopped --name pluto-for-channels -p 8080:80 jonmaddox/pluto-for-channels
### Required: Authentication

You must provide your Pluto TV credentials via environment variables:

- `PLUTO_USERNAME` - Your Pluto TV account email
- `PLUTO_PASSWORD` - Your Pluto TV account password

Running the container:

docker run -d --restart unless-stopped --name pluto-for-channels -p 8080:80 \
-e PLUTO_USERNAME='your@email.com' \
-e PLUTO_PASSWORD='yourpassword' \
jonmaddox/pluto-for-channels

You can retrieve the playlist and EPG via the status page.

Expand All @@ -22,7 +34,11 @@ By using the `VERSIONS` env var when starting the docker container, you can tell

Simply provide a comma separated list of words without spaces with the `VERSIONS` env var.

docker run -d --restart unless-stopped --name pluto-for-channels -p 8080:80 -e VERSIONS=Dad,Bob,Joe jonmaddox/pluto-for-channels
docker run -d --restart unless-stopped --name pluto-for-channels -p 8080:80 \
-e PLUTO_USERNAME='your@email.com' \
-e PLUTO_PASSWORD='yourpassword' \
-e VERSIONS=Dad,Bob,Joe \
jonmaddox/pluto-for-channels

### Optionally provide a starting channel number

Expand All @@ -32,9 +48,13 @@ You should use a starting number greater than 10000, so that the channel numbers

For example, channel 345 will be 10345. Channel 2102 will be 12102.

Simpley provide a starting number with the `START` env var.
Simply provide a starting number with the `START` env var.

docker run -d --restart unless-stopped --name pluto-for-channels -p 8080:80 -e START=80000 jonmaddox/pluto-for-channels
docker run -d --restart unless-stopped --name pluto-for-channels -p 8080:80 \
-e PLUTO_USERNAME='your@email.com' \
-e PLUTO_PASSWORD='yourpassword' \
-e START=80000 \
jonmaddox/pluto-for-channels

## Add Source to Channels

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.2.15
1.3.0
2 changes: 1 addition & 1 deletion entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ get_latest_release() {

while :
do
START=$START node index.js $VERSIONS
PLUTO_USERNAME=$PLUTO_USERNAME PLUTO_PASSWORD=$PLUTO_PASSWORD START=$START node index.js $VERSIONS

CURRENT_VERSION=`cat VERSION`
LATEST_VERSION=`get_latest_release`
Expand Down
Loading