├── Procfile ├── docker-compose.yml ├── render.yaml ├── netlify.toml ├── fly.toml ├── vercel.json ├── node ├── Dockerfile ├── package.json └── index.js ├── plex └── fetchPlexChannels.php ├── .github └── workflows │ └── get-channels.yml ├── README.md └── code.gs /Procfile: -------------------------------------------------------------------------------- 1 | web: node node/index.js 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | iptv-app: 5 | image: dtankdemp/free-iptv-channels 6 | ports: 7 | - "8765:4242" 8 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | name: multiservice 4 | env: node 5 | buildCommand: "npm install" 6 | startCommand: "node node/index.js" 7 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm install" 3 | functions = "node" 4 | 5 | [[redirects]] 6 | from = "/*" 7 | to = "/.netlify/functions/index" 8 | status = 200 9 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "multiservice" 2 | 3 | [build] 4 | builder = "paketobuildpacks/builder:base" 5 | 6 | [env] 7 | NODE_ENV = "production" 8 | 9 | [deploy] 10 | release_command = "node node/index.js" 11 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "node/index.js", 6 | "use": "@vercel/node" 7 | } 8 | ], 9 | "routes": [ 10 | { 11 | "src": "/(.*)", 12 | "dest": "node/index.js" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /node/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Node.js image from Docker Hub 2 | FROM node:18-alpine 3 | 4 | # Set the working directory inside the container 5 | WORKDIR /usr/src/app 6 | 7 | # Copy the current directory contents into the container at /usr/src/app 8 | COPY . . 9 | 10 | # Expose port 4242 for the application 11 | EXPOSE 4242 12 | 13 | # Start the Node.js application 14 | CMD ["node", "index.js"] -------------------------------------------------------------------------------- /node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "free-iptv-channels", 3 | "version": "1.0.0", 4 | "description": "Generates an m3u8 playlist from Pluto, Samsung, Stirr, Plex, PBS, and Roku channels.", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [ 11 | "IPTV", 12 | "Plex", 13 | "Node.js", 14 | "channels" 15 | ], 16 | "author": "", 17 | "license": "ISC", 18 | "dependencies": {} 19 | } 20 | -------------------------------------------------------------------------------- /plex/fetchPlexChannels.php: -------------------------------------------------------------------------------- 1 | $channel['media_title'], 21 | 'Genre' => $genre, 22 | 'Language' => $channel['media_lang'], 23 | 'Summary' => $channel['media_summary'], 24 | 'Link' => $channel['media_link'] 25 | ]; 26 | } 27 | } 28 | } 29 | 30 | $jsonOutput = json_encode($result, JSON_PRETTY_PRINT); 31 | $filePath = 'channels.json'; 32 | if (file_put_contents($filePath, $jsonOutput)) { 33 | echo "File successfully saved to $filePath"; 34 | } else { 35 | echo "Failed to save file."; 36 | } 37 | ?> 38 | -------------------------------------------------------------------------------- /.github/workflows/get-channels.yml: -------------------------------------------------------------------------------- 1 | name: Fetch Plex Channels 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * 0' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | scrape-and-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # Checkout code with full history for reset 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | with: 16 | token: ${{ secrets.GITHUB_TOKEN }} 17 | fetch-depth: 0 18 | 19 | # Set up PHP environment 20 | - name: Set up PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: '8.0' 24 | 25 | # Run the PHP script 26 | - name: Run PHP script 27 | run: | 28 | php plex/fetchPlexChannels.php || exit 1 29 | 30 | # Squash changes and force push (to avoid history buildup) 31 | - name: Squash and force push changes 32 | if: success() 33 | run: | 34 | # Set up git config (locally for this repository) 35 | git config user.email "github-actions[bot]@users.noreply.github.com" 36 | git config user.name "github-actions[bot]" 37 | 38 | # Reset to the first commit 39 | FIRST_COMMIT=$(git rev-list --max-parents=0 HEAD) 40 | git reset --soft $FIRST_COMMIT 41 | 42 | # Add all changes to a new commit 43 | git add plex/channels.json 44 | git commit -m "Update Plex Channels" || echo "No changes to commit" 45 | 46 | # Force push the new history 47 | git push -f origin main 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [Deploy with Vercel](https://vercel.com/new/clone?repository-url=https://github.com/dtankdempse/free-iptv-channels/tree/master/node&project-name=multiservice&repo-name=multiservice) 2 | [Deploy with Netlify](https://app.netlify.com/start/deploy?repository=https://github.com/dtankdempse/free-iptv-channels) 3 | [Deploy to Heroku](https://heroku.com/deploy?template=https://github.com/dtankdempse/free-iptv-channels) 4 | [Deploy to Render](https://render.com/deploy?repo=https://github.com/dtankdempse/free-iptv-channels) 5 | [Deploy to Fly.io](https://fly.io/launch?source=https://github.com/dtankdempse/free-iptv-channels) 6 | [Deploy to AWS Amplify](https://console.aws.amazon.com/amplify/home#/deploy?repo=https://github.com/dtankdempse/free-iptv-channels) 7 | 8 | # Pluto, Samsung, Stirr, Tubi, Plex, PBS and Roku Playlist (M3U8) 9 | 10 | This script generates an m3u8 playlist from the channels provided by services such as Pluto, Samsung, Stirr, Plex, PBS, and Roku. It is based on the original script created by matthuisman, which can be found at [matthuisman's GitHub repository](https://github.com/matthuisman/i.mjh.nz). 11 | 12 | ### Script Access URL 13 | 14 | Use the following URL to access the hosted script. Replace the `ADD_REGION` and `ADD_SERVICE` placeholders with your desired values. 15 | 16 | `https://tinyurl.com/multiservice21?region=ADD_REGION&service=ADD_SERVICE` 17 | 18 | After customizing the URL by replacing the ADD_REGION and ADD_SERVICE placeholders with your desired region and service (e.g., us for the US region and PlutoTV for the service), copy the complete URL and paste it into the "Add Playlist" or "M3U8 URL" section of your IPTV application. Once added, the app will load both the channels and the guide information 19 | 20 | **⚠️ Please note:** It is recommended to add the Google Apps Script to your own Google account or deploy the script to one of the other services, rather than relying on this publicly shared URL long-term. 21 | 22 | ### Available Service Parameter Values 23 | 24 | Choose one of the following services to include in the `service` parameter: 25 | 26 | - Plex 27 | - Roku 28 | - SamsungTVPlus 29 | - PlutoTV 30 | - PBS 31 | - PBSKids 32 | - Stirr 33 | - Tubi 34 | 35 | ### Available Region Parameter Values 36 | 37 | Use one of these region codes to specify the region in the `region` parameter: 38 | 39 | - `all` (for all regions) 40 | - `ar` (Argentina) 41 | - `br` (Brazil) 42 | - `ca` (Canada) 43 | - `cl` (Chile) 44 | - `de` (Germany) 45 | - `dk` (Denmark) 46 | - `es` (Spain) 47 | - `fr` (France) 48 | - `gb` (United Kingdom) 49 | - `mx` (Mexico) 50 | - `no` (Norway) 51 | - `se` (Sweden) 52 | - `us` (United States) 53 | 54 | ### Available Sorting Parameter Values (optional) 55 | 56 | Use one of the following options in the `sort` parameter to specify how you want to sort the channels: 57 | 58 | - `name` (default): 59 | Sorts the channels alphabetically by their name. 60 | 61 | - `chno`: 62 | Sorts the channels by their assigned channel number. 63 | 64 | ### How to Add the Script to Your Google Account (code.gs) 65 | 66 | Go here and click the "New Project" button in the upper left corner. Then, copy the script from code.gs and paste it into the script editor. Once done, deploy the script. 67 | 68 | Follow this video tutorial to learn how to deploy a Google Apps Script: 69 | 70 | [How to Deploy a Google Web App](https://www.youtube.com/watch?v=-AlstV1PAaA) 71 | 72 | During the deployment process, make sure to select **"Anyone"** for the "Who has access" option, so the app can access the URL and load without requiring authentication. 73 | 74 | Once deployed, you will get a URL similar to: 75 | 76 | `https://script.google.com/macros/s/...gwlprM_Kn10kT7LGk/exec` 77 | 78 | To use the script, you need to add the `region` and `service` parameters at the end of the URL. For example: 79 | 80 | `https://script.google.com/macros/s/...gwlprM_Kn10kT7LGk/exec?region=us&service=Plex` 81 | 82 | Simply replace `region=us` and `service=Plex` with the appropriate region and service values from the available parameters listed above. 83 | 84 | **Tip:** For a cleaner and more concise URL, consider using a URL shortener like [tinyurl.com](https://tinyurl.com/) and appending the necessary parameters at the end. 85 | 86 | ## Using the Docker Image 87 | 88 | Pull the latest version: 89 | `docker pull dtankdemp/free-iptv-channels` 90 | 91 | Run the container: 92 | `docker run -p :4242 dtankdemp/free-iptv-channels` 93 | 94 | Replace with the desired port (e.g., 8080). 95 | 96 | Access the application: 97 | Visit `http://localhost:` in your browser. 98 | 99 | ## **Running the Script With Node** 100 | 101 | The script can also be executed locally as a standalone Node.js server without relying on any external libraries or frameworks. To run it, simply navigate to the project directory and use the following command: 102 | 103 | `node node/index.js` 104 | 105 | Once the server is running, you can access it locally by navigating to: 106 | 107 | `http://localhost:4242` 108 | 109 | ### EPG for TV Guide Information 110 | 111 | The EPG URLs are embedded directly within the playlists. If you'd prefer to manually add the EPG guide, you can find the relevant URLs for each service on 112 | this [page](https://github.com/matthuisman/i.mjh.nz/). 113 | 114 | --- 115 | 116 |
117 | Click to read Disclaimer. 118 | 119 | ### Disclaimer: 120 | 121 | This repository has no control over the streams, links, or the legality of the content provided by Pluto, Samsung, Stirr, Tubi, Plex, PBS, and Roku. Additionally, this script simply converts the JSON files provided by i.mjh.nz into an M3U8 playlist. It is the end user's responsibility to ensure the legal use of these playlists. We strongly recommend verifying that the content complies with the laws and regulations of your country before use. 122 | 123 |
124 | 125 |
126 | Click to read DMCA Notice. 127 | 128 | ### DMCA Notice: 129 | 130 | This repository does not host or store any video files. It simply organizes publicly accessible web links, which can be accessed through a web browser, into an M3U-formatted playlist. To the best of our knowledge, the content was intentionally made publicly available by the copyright holders or with their permission and consent granted to these websites to stream and share the content they provide. 131 | 132 | Please note that linking does not directly infringe copyright, as no copies are made on this repository or its servers. Therefore, sending a DMCA notice to GitHub or the maintainers of this repository is not a valid course of action. To remove the content from the web, you should contact the website or hosting provider actually hosting the material. 133 | 134 | If you still believe a link infringes on your rights, you can request its removal by opening an [issue](https://github.com/dtankdempse/free-iptv-channels/issues) or submitting a [pull request](https://github.com/dtankdempse/free-iptv-channels/pulls). Be aware, however, that removing a link here will not affect the content hosted on the external websites, as this repository has no control over the files or the content being provided. 135 | 136 |
137 | -------------------------------------------------------------------------------- /code.gs: -------------------------------------------------------------------------------- 1 | function doGet(e) { 2 | const params = e.parameter; 3 | const region = (params.region || 'us').toLowerCase().trim(); 4 | const service = params.service; 5 | const sort = params.sort || 'name'; 6 | 7 | if (!service) { 8 | return handleError('Error: No service type provided'); 9 | } 10 | 11 | // Handle Pluto TV service 12 | if (service.toLowerCase() === 'plutotv') { 13 | return handlePluto(region, sort); 14 | } 15 | 16 | // Handle Plex service 17 | if (service.toLowerCase() === 'plex') { 18 | return handlePlex(region, sort); 19 | } 20 | 21 | // Handle SamsungTVPlus service 22 | if (service.toLowerCase() === 'samsungtvplus') { 23 | return handleSamsungTVPlus(region, sort); 24 | } 25 | 26 | // Handle Roku service 27 | if (service.toLowerCase() === 'roku') { 28 | return handleRoku(sort); 29 | } 30 | 31 | // Handle Stirr service 32 | if (service.toLowerCase() === 'stirr') { 33 | return handleStirr(sort); 34 | } 35 | 36 | // Handle Tubi service 37 | if (service.toLowerCase() === 'tubi') { 38 | return handleTubi(service); 39 | } 40 | 41 | // Handle PBSKids service 42 | if (service.toLowerCase() === 'pbskids') { 43 | return handlePBSKids(service); 44 | } 45 | 46 | // Handle PBS service 47 | if (service.toLowerCase() === 'pbs') { 48 | return handlePBS(); 49 | } 50 | 51 | // If no matching service was found, return an error 52 | return handleError('Error: Unsupported service type provided'); 53 | 54 | } 55 | 56 | //------ Service Functions ------// 57 | 58 | function handlePluto(region, sort) { 59 | const PLUTO_URL = 'https://i.mjh.nz/PlutoTV/.channels.json.gz'; 60 | const STREAM_URL_TEMPLATE = 'https://jmp2.uk/plu-{id}.m3u8'; 61 | 62 | sort = sort || 'name'; 63 | 64 | let data; 65 | 66 | try { 67 | Logger.log('Fetching new Pluto data from URL: ' + PLUTO_URL); 68 | 69 | // Fetch the gzipped file 70 | const response = UrlFetchApp.fetch(PLUTO_URL); 71 | let gzipBlob = response.getBlob(); 72 | 73 | // Set content type to application/x-gzip (Gzip Bug Workaround) 74 | gzipBlob = gzipBlob.setContentType('application/x-gzip'); 75 | 76 | // Decompress the gzipped data 77 | const extractedBlob = Utilities.ungzip(gzipBlob); 78 | const extractedData = extractedBlob.getDataAsString(); 79 | 80 | // Parse JSON data 81 | data = JSON.parse(extractedData); 82 | 83 | Logger.log('Data successfully extracted and parsed.'); 84 | } catch (error) { 85 | Logger.log('Error fetching or processing Pluto data: ' + error.message); 86 | return handleError('Error fetching Pluto data: ' + error.message); 87 | } 88 | 89 | let output = `#EXTM3U url-tvg="https://github.com/matthuisman/i.mjh.nz/raw/master/PlutoTV/${region}.xml.gz"\n`; 90 | const regionNameMap = { 91 | ar: "Argentina", 92 | br: "Brazil", 93 | ca: "Canada", 94 | cl: "Chile", 95 | de: "Germany", 96 | dk: "Denmark", 97 | es: "Spain", 98 | fr: "France", 99 | gb: "United Kingdom", 100 | it: "Italy", 101 | mx: "Mexico", 102 | no: "Norway", 103 | se: "Sweden", 104 | us: "United States" 105 | }; 106 | let channels = {}; 107 | 108 | if (region === 'all') { 109 | for (const regionKey in data.regions) { 110 | const regionData = data.regions[regionKey]; 111 | const regionFullName = regionNameMap[regionKey] || regionKey.toUpperCase(); 112 | for (const channelKey in regionData.channels) { 113 | const channel = { ...regionData.channels[channelKey], region: regionFullName }; 114 | const uniqueChannelId = `${channelKey}-${regionKey}`; 115 | channels[uniqueChannelId] = channel; 116 | } 117 | } 118 | } else { 119 | if (!data.regions[region]) { 120 | return handleError(`Error: Region '${region}' not found in Pluto data.`); 121 | } 122 | channels = data.regions[region].channels || {}; 123 | } 124 | 125 | const sortedChannelIds = Object.keys(channels).sort((a, b) => { 126 | const channelA = channels[a]; 127 | const channelB = channels[b]; 128 | if (sort === 'chno') { 129 | return channelA.chno - channelB.chno; 130 | } else { 131 | return channelA.name.localeCompare(channelB.name); 132 | } 133 | }); 134 | 135 | sortedChannelIds.forEach(channelId => { 136 | const channel = channels[channelId]; 137 | const { chno, name, description, group, logo, region: channelRegion } = channel; 138 | 139 | const groupTitle = region === 'all' ? `${channelRegion}` : group; 140 | 141 | output += `#EXTINF:-1 channel-id="${channelId}" tvg-id="${channelId}" tvg-chno="${chno}" tvg-name="${name}" tvg-logo="${logo}" group-title="${groupTitle}", ${name}\n`; 142 | output += STREAM_URL_TEMPLATE.replace('{id}', channelId.split('-')[0]) + '\n'; 143 | }); 144 | 145 | output = output.replace(/tvg-id="(.*?)-\w{2}"/g, 'tvg-id="$1"'); 146 | 147 | return ContentService.createTextOutput(output).setMimeType(ContentService.MimeType.TEXT); 148 | } 149 | 150 | function handlePlex(region, sort) { 151 | const PLEX_URL = 'https://i.mjh.nz/Plex/.channels.json.gz'; 152 | const CHANNELS_JSON_URL = 'https://raw.githubusercontent.com/dtankdempse/free-iptv-channels/main/plex/channels.json'; 153 | const STREAM_URL_TEMPLATE = 'https://jmp2.uk/plex-{id}.m3u8'; 154 | 155 | sort = sort || 'name'; 156 | let data; 157 | let plexChannels = []; 158 | 159 | try { 160 | Logger.log('Fetching new Plex data from URL: ' + PLEX_URL); 161 | 162 | // Fetch the gzipped file 163 | const response = UrlFetchApp.fetch(PLEX_URL); 164 | let gzipBlob = response.getBlob(); 165 | 166 | // Set content type to application/x-gzip (Gzip Bug Workaround) 167 | gzipBlob = gzipBlob.setContentType('application/x-gzip'); 168 | 169 | // Decompress the gzipped data 170 | const extractedBlob = Utilities.ungzip(gzipBlob); 171 | const extractedData = extractedBlob.getDataAsString(); 172 | 173 | // Parse JSON data 174 | data = JSON.parse(extractedData); 175 | 176 | Logger.log('Fetching new channels.json data from URL: ' + CHANNELS_JSON_URL); 177 | const channelsResponse = UrlFetchApp.fetch(CHANNELS_JSON_URL); 178 | plexChannels = JSON.parse(channelsResponse.getContentText()); 179 | } catch (error) { 180 | return handleError('Error fetching Plex or channels data: ' + error.message); 181 | } 182 | 183 | let output = `#EXTM3U url-tvg="https://github.com/matthuisman/i.mjh.nz/raw/master/Plex/${region}.xml.gz"\n`; 184 | const regionNameMap = { 185 | us: "United States", 186 | mx: "Mexico", 187 | es: "Spain", 188 | ca: "Canada", 189 | au: "Australia", 190 | nz: "New Zealand" 191 | }; 192 | let channels = {}; 193 | 194 | // Process channels based on region 195 | if (region === 'all') { 196 | for (const regionKey in data.regions) { 197 | const regionData = data.regions[regionKey]; 198 | const regionFullName = regionNameMap[regionKey] || regionKey.toUpperCase(); 199 | 200 | for (const channelKey in data.channels) { 201 | const channel = data.channels[channelKey]; 202 | if (channel.regions.includes(regionKey)) { 203 | const uniqueChannelId = `${channelKey}-${regionKey}`; 204 | channels[uniqueChannelId] = { ...channel, region: regionFullName, group: regionFullName, originalId: channelKey }; 205 | } 206 | } 207 | } 208 | } else { 209 | if (!data.regions[region]) { 210 | return handleError(`Error: Region '${region}' not found in Plex data.`); 211 | } 212 | for (const channelKey in data.channels) { 213 | const channel = data.channels[channelKey]; 214 | if (channel.regions.includes(region)) { 215 | const matchingChannel = plexChannels.find(ch => ch.Title === channel.name); 216 | const genre = matchingChannel && matchingChannel.Genre ? matchingChannel.Genre : 'Uncategorized'; 217 | channels[channelKey] = { ...channel, group: genre, originalId: channelKey }; 218 | } 219 | } 220 | } 221 | 222 | // Sort channels based on the specified sorting criteria 223 | const sortedChannelIds = Object.keys(channels).sort((a, b) => { 224 | const channelA = channels[a]; 225 | const channelB = channels[b]; 226 | return sort === 'chno' ? (channelA.chno - channelB.chno) : channelA.name.localeCompare(channelB.name); 227 | }); 228 | 229 | sortedChannelIds.forEach(channelId => { 230 | const channel = channels[channelId]; 231 | const { chno, name, logo, group, originalId } = channel; 232 | 233 | output += `#EXTINF:-1 channel-id="${channelId}" tvg-id="${channelId}" tvg-chno="${chno || ''}" tvg-name="${name}" tvg-logo="${logo}" group-title="${group}", ${name}\n`; 234 | output += STREAM_URL_TEMPLATE.replace('{id}', originalId) + '\n'; 235 | }); 236 | 237 | output = output.replace(/tvg-id="(.*?)-\w{2}"/g, 'tvg-id="$1"'); 238 | 239 | // Return output directly to the browser 240 | return ContentService.createTextOutput(output).setMimeType(ContentService.MimeType.TEXT); 241 | } 242 | 243 | function handleSamsungTVPlus(region, sort) { 244 | const SAMSUNG_URL = 'https://i.mjh.nz/SamsungTVPlus/.channels.json.gz'; 245 | const STREAM_URL_TEMPLATE = 'https://jmp2.uk/sam-{id}.m3u8'; 246 | 247 | // Set a default for `sort` if not provided 248 | sort = sort || 'name'; 249 | 250 | let data; 251 | 252 | try { 253 | Logger.log('Fetching new SamsungTVPlus data from URL: ' + SAMSUNG_URL); 254 | 255 | // Fetch the gzipped file 256 | const response = UrlFetchApp.fetch(SAMSUNG_URL); 257 | 258 | let gzipBlob = response.getBlob(); 259 | 260 | // Set content type to application/x-gzip (Gzip Bug Workaround) 261 | gzipBlob = gzipBlob.setContentType('application/x-gzip'); 262 | 263 | // Decompress the gzipped data 264 | const extractedBlob = Utilities.ungzip(gzipBlob); 265 | const extractedData = extractedBlob.getDataAsString(); 266 | 267 | // Parse JSON data 268 | data = JSON.parse(extractedData); 269 | 270 | } catch (error) { 271 | Logger.log('Error fetching or processing SamsungTVPlus data: ' + error.message); 272 | return handleError('Error fetching SamsungTVPlus data: ' + error.message); 273 | } 274 | 275 | let output = `#EXTM3U url-tvg="https://github.com/matthuisman/i.mjh.nz/raw/master/SamsungTVPlus/${region}.xml.gz"\n`; 276 | let channels = {}; 277 | 278 | // If "all" is specified, gather channels from each region 279 | if (region === 'all') { 280 | for (const regionKey in data.regions) { 281 | const regionData = data.regions[regionKey]; 282 | const regionFullName = regionData.name || regionKey.toUpperCase(); 283 | for (const channelKey in regionData.channels) { 284 | const channel = { ...regionData.channels[channelKey], region: regionFullName }; 285 | const uniqueChannelId = `${channelKey}-${regionKey}`; 286 | channels[uniqueChannelId] = channel; 287 | } 288 | } 289 | } else { 290 | // Handle a single specified region 291 | if (!data.regions[region]) { 292 | return handleError(`Error: Region '${region}' not found in SamsungTVPlus data.`); 293 | } 294 | channels = data.regions[region].channels || {}; 295 | } 296 | 297 | // Sort channels based on the specified sorting criteria 298 | const sortedChannelIds = Object.keys(channels).sort((a, b) => { 299 | const channelA = channels[a]; 300 | const channelB = channels[b]; 301 | if (sort === 'chno') { 302 | return channelA.chno - channelB.chno; 303 | } else { 304 | return channelA.name.localeCompare(channelB.name); 305 | } 306 | }); 307 | 308 | sortedChannelIds.forEach(channelId => { 309 | const channel = channels[channelId]; 310 | const { chno, name, description, group, logo, region: channelRegion } = channel; 311 | 312 | // Include region name in group title when "all" is specified 313 | const groupTitle = region === 'all' ? `${channelRegion}` : group; 314 | 315 | output += `#EXTINF:-1 channel-id="${channelId}" tvg-id="${channelId}" tvg-chno="${chno}" tvg-name="${name}" tvg-logo="${logo}" group-title="${groupTitle}", ${name}\n`; 316 | output += STREAM_URL_TEMPLATE.replace('{id}', channelId.split('-')[0]) + '\n'; 317 | }); 318 | 319 | output = output.replace(/tvg-id="(.*?)-\w{2}"/g, 'tvg-id="$1"'); 320 | 321 | // Return output directly to the browser 322 | return ContentService.createTextOutput(output).setMimeType(ContentService.MimeType.TEXT); 323 | } 324 | 325 | function handleRoku(sort) { 326 | const ROKU_URL = 'https://i.mjh.nz/Roku/.channels.json.gz'; 327 | const STREAM_URL_TEMPLATE = 'https://jmp2.uk/rok-{id}.m3u8'; 328 | 329 | // Set a default for `sort` if not provided 330 | sort = sort || 'name'; 331 | 332 | let data; 333 | 334 | try { 335 | Logger.log('Fetching new Roku data from URL: ' + ROKU_URL); 336 | 337 | // Fetch the gzipped file 338 | const response = UrlFetchApp.fetch(ROKU_URL); 339 | 340 | let gzipBlob = response.getBlob(); 341 | 342 | // Set content type to application/x-gzip (Gzip Bug Workaround) 343 | gzipBlob = gzipBlob.setContentType('application/x-gzip'); 344 | 345 | // Decompress the gzipped data 346 | const extractedBlob = Utilities.ungzip(gzipBlob); 347 | const extractedData = extractedBlob.getDataAsString(); 348 | 349 | // Parse JSON data 350 | data = JSON.parse(extractedData); 351 | 352 | } catch (error) { 353 | Logger.log('Error fetching or processing Roku data: ' + error.message); 354 | return handleError('Error fetching Roku data: ' + error.message); 355 | } 356 | 357 | let output = `#EXTM3U url-tvg="https://github.com/matthuisman/i.mjh.nz/raw/master/Roku/all.xml.gz"\n`; 358 | let channels = data.channels || {}; 359 | 360 | // Sort channels based on the specified sorting criteria 361 | const sortedChannelIds = Object.keys(channels).sort((a, b) => { 362 | const channelA = channels[a]; 363 | const channelB = channels[b]; 364 | if (sort === 'chno') { 365 | return channelA.chno - channelB.chno; 366 | } else { 367 | return channelA.name.localeCompare(channelB.name); 368 | } 369 | }); 370 | 371 | sortedChannelIds.forEach(channelId => { 372 | const channel = channels[channelId]; 373 | const { chno, name, description, groups, logo } = channel; 374 | 375 | // Use the first group in `groups` array for `group-title` 376 | const groupTitle = groups && groups.length > 0 ? groups[0] : 'Uncategorized'; 377 | 378 | output += `#EXTINF:-1 channel-id="${channelId}" tvg-id="${channelId}" tvg-chno="${chno}" tvg-name="${name}" tvg-logo="${logo}" group-title="", ${name}\n`; 379 | output += STREAM_URL_TEMPLATE.replace('{id}', channelId) + '\n'; 380 | }); 381 | 382 | // Return output directly to the browser 383 | return ContentService.createTextOutput(output).setMimeType(ContentService.MimeType.TEXT); 384 | } 385 | 386 | function handleStirr(sort) { 387 | const STIRR_URL = 'https://i.mjh.nz/Stirr/.channels.json.gz'; 388 | 389 | // Set a default for `sort` if not provided 390 | sort = sort || 'name'; 391 | 392 | let data; 393 | 394 | try { 395 | Logger.log('Fetching new Stirr data from URL: ' + STIRR_URL); 396 | 397 | // Fetch the gzipped file 398 | const response = UrlFetchApp.fetch(STIRR_URL); 399 | 400 | let gzipBlob = response.getBlob(); 401 | 402 | // Set content type to application/x-gzip (Gzip Bug Workaround) 403 | gzipBlob = gzipBlob.setContentType('application/x-gzip'); 404 | 405 | // Decompress the gzipped data 406 | const extractedBlob = Utilities.ungzip(gzipBlob); 407 | const extractedData = extractedBlob.getDataAsString(); 408 | 409 | // Parse JSON data 410 | data = JSON.parse(extractedData); 411 | 412 | } catch (error) { 413 | Logger.log('Error fetching or processing Stirr data: ' + error.message); 414 | return handleError('Error fetching Stirr data: ' + error.message); 415 | } 416 | 417 | let output = `#EXTM3U url-tvg="https://i.mjh.nz/Stirr/all.xml.gz"\n`; 418 | let channels = data.channels || {}; 419 | 420 | // Sort channels based on the specified sorting criteria 421 | const sortedChannelIds = Object.keys(channels).sort((a, b) => { 422 | const channelA = channels[a]; 423 | const channelB = channels[b]; 424 | if (sort === 'chno') { 425 | return channelA.chno - channelB.chno; 426 | } else { 427 | return channelA.name.localeCompare(channelB.name); 428 | } 429 | }); 430 | 431 | sortedChannelIds.forEach(channelId => { 432 | const channel = channels[channelId]; 433 | const { chno, name, groups, logo } = channel; 434 | 435 | // Concatenate all groups, separated by commas 436 | const groupTitle = groups && groups.length > 0 ? groups.join(', ') : 'Uncategorized'; 437 | 438 | // Generate the stream URL using the template 439 | const streamUrl = `https://jmp2.uk/str-${channelId}.m3u8`; 440 | 441 | output += `#EXTINF:-1 channel-id="${channelId}" tvg-id="${channelId}" tvg-chno="${chno}" tvg-name="${name}" tvg-logo="${logo}" group-title="${groupTitle}", ${name}\n`; 442 | output += `${streamUrl}\n`; 443 | }); 444 | 445 | // Return output directly to the browser 446 | return ContentService.createTextOutput(output).setMimeType(ContentService.MimeType.TEXT); 447 | } 448 | 449 | function handleTubi(service) { 450 | let data; 451 | 452 | try { 453 | Logger.log('Fetching new Tubi data'); 454 | const playlistUrl = 'https://github.com/dtankdempse/tubi-m3u/raw/refs/heads/main/tubi_playlist_us.m3u'; 455 | const response = UrlFetchApp.fetch(playlistUrl); 456 | data = response.getContentText(); 457 | 458 | let epgUrl = 'https://raw.githubusercontent.com/dtankdempse/tubi-m3u/refs/heads/main/tubi_epg_us.xml'; 459 | let output = `#EXTM3U url-tvg="${epgUrl}"\n`; 460 | output += data; 461 | 462 | // Return output directly to the browser 463 | return ContentService.createTextOutput(output).setMimeType(ContentService.MimeType.TEXT); 464 | } catch (error) { 465 | Logger.log('Error fetching Tubi data: ' + error.message); 466 | return handleError('Error fetching Tubi data: ' + error.message); 467 | } 468 | } 469 | 470 | function handlePBSKids(service) { 471 | if (service.toLowerCase() !== 'pbskids') return; 472 | 473 | let data; 474 | 475 | try { 476 | Logger.log('Fetching new PBS Kids data'); 477 | const APP_URL = 'https://i.mjh.nz/PBS/.kids_app.json.gz'; 478 | 479 | // Fetch the gzipped file 480 | const response = UrlFetchApp.fetch(APP_URL); 481 | 482 | let gzipBlob = response.getBlob(); 483 | 484 | // Set content type to application/x-gzip (Gzip Bug Workaround) 485 | gzipBlob = gzipBlob.setContentType('application/x-gzip'); 486 | 487 | // Decompress the gzipped data 488 | const extractedBlob = Utilities.ungzip(gzipBlob); 489 | const extractedData = extractedBlob.getDataAsString(); 490 | 491 | // Parse JSON data 492 | data = JSON.parse(extractedData); 493 | 494 | let output = `#EXTM3U url-tvg="https://github.com/matthuisman/i.mjh.nz/raw/master/PBS/kids_all.xml.gz"\n`; 495 | 496 | // Sort the channels by name before iterating 497 | const sortedKeys = Object.keys(data.channels).sort((a, b) => { 498 | const channelA = data.channels[a].name.toLowerCase(); 499 | const channelB = data.channels[b].name.toLowerCase(); 500 | return channelA.localeCompare(channelB); // Sort alphabetically by name 501 | }); 502 | 503 | sortedKeys.forEach(key => { 504 | const channel = data.channels[key]; 505 | const { logo, name, url } = channel; 506 | 507 | output += `#EXTINF:-1 channel-id="pbskids-${key}" tvg-id="${key}" tvg-name="${name}" tvg-logo="${logo}", ${name}\n${url}\n`; 508 | }); 509 | 510 | // Return output directly to the browser 511 | return ContentService.createTextOutput(output).setMimeType(ContentService.MimeType.TEXT); 512 | 513 | } catch (error) { 514 | Logger.log('Error fetching PBS Kids data: ' + error.message); 515 | return handleError('Error fetching PBS Kids data: ' + error.message); 516 | } 517 | } 518 | 519 | function handlePBS() { 520 | const DATA_URL = 'https://i.mjh.nz/PBS/.app.json.gz'; 521 | const EPG_URL = 'https://i.mjh.nz/PBS/all.xml.gz'; 522 | 523 | let data; 524 | 525 | try { 526 | Logger.log('Fetching new PBS data from URL: ' + DATA_URL); 527 | 528 | // Fetch the gzipped file 529 | const response = UrlFetchApp.fetch(DATA_URL); 530 | 531 | let gzipBlob = response.getBlob(); 532 | 533 | // Set content type to application/x-gzip (Gzip Bug Workaround) 534 | gzipBlob = gzipBlob.setContentType('application/x-gzip'); 535 | 536 | // Decompress the gzipped data 537 | const extractedBlob = Utilities.ungzip(gzipBlob); 538 | const extractedData = extractedBlob.getDataAsString(); 539 | 540 | // Parse JSON data 541 | data = JSON.parse(extractedData); 542 | 543 | } catch (error) { 544 | Logger.log('Error fetching or processing PBS data: ' + error.message); 545 | return handleError('Error fetching PBS data: ' + error.message); 546 | } 547 | 548 | // Format data for M3U8 549 | let output = `#EXTM3U x-tvg-url="${EPG_URL}"\n`; 550 | 551 | Object.keys(data.channels).forEach(key => { 552 | const channel = data.channels[key]; 553 | output += `#EXTINF:-1 channel-id="pbs-${key}" tvg-id="${key}" tvg-name="${channel.name}" tvg-logo="${channel.logo}", ${channel.name}\n`; 554 | output += `#KODIPROP:inputstream.adaptive.manifest_type=mpd\n`; 555 | output += `#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha\n`; 556 | output += `#KODIPROP:inputstream.adaptive.license_key=${channel.license}|Content-Type=application%2Foctet-stream&user-agent=okhttp%2F4.9.0|R{SSM}|\n`; 557 | output += `${channel.url}|user-agent=okhttp%2F4.9.0\n`; 558 | }); 559 | 560 | // Return output directly to the browser 561 | return ContentService.createTextOutput(output).setMimeType(ContentService.MimeType.TEXT); 562 | } 563 | 564 | //------ Other Functions ------// 565 | 566 | function handleError(errorMessage) { 567 | return ContentService.createTextOutput(errorMessage) 568 | .setMimeType(ContentService.MimeType.TEXT); 569 | } 570 | -------------------------------------------------------------------------------- /node/index.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const https = require('https'); 3 | const url = require('url'); 4 | const zlib = require('zlib'); 5 | 6 | const hostname = '0.0.0.0'; 7 | const port = 4242; 8 | 9 | const regionNameMap = { 10 | ar: "Argentina", 11 | br: "Brazil", 12 | ca: "Canada", 13 | cl: "Chile", 14 | de: "Germany", 15 | dk: "Denmark", 16 | es: "Spain", 17 | fr: "France", 18 | gb: "United Kingdom", 19 | it: "Italy", 20 | mx: "Mexico", 21 | no: "Norway", 22 | se: "Sweden", 23 | us: "United States" 24 | }; 25 | 26 | const server = http.createServer(async (req, res) => { 27 | const parsedUrl = url.parse(req.url, true); 28 | const params = parsedUrl.query; 29 | const pathname = parsedUrl.pathname; 30 | const region = (params.region || 'us').toLowerCase().trim(); 31 | const service = params.service; 32 | const sort = params.sort || 'name'; 33 | 34 | // Check for the root path "/" 35 | if (pathname === '/' && !parsedUrl.query.service) { 36 | return handleHomePage(res); 37 | } 38 | 39 | // Check if the service parameter is missing 40 | if (!service) { 41 | res.writeHead(400, { 42 | 'Content-Type': 'text/plain' 43 | }); 44 | return res.end('Error: No service type provided'); 45 | } 46 | 47 | // Handle Pluto TV service 48 | if (service.toLowerCase() === 'plutotv') { 49 | const plutoOutput = await handlePlutoTV(region, sort); 50 | res.writeHead(200, { 51 | 'Content-Type': 'text/plain' 52 | }); 53 | return res.end(plutoOutput); 54 | } 55 | 56 | // Handle Plex service 57 | if (service.toLowerCase() === 'plex') { 58 | const plexOutput = await handlePlex(region, sort); 59 | res.writeHead(200, { 60 | 'Content-Type': 'text/plain' 61 | }); 62 | return res.end(plexOutput); 63 | } 64 | 65 | // Handle SamsungTVPlus service 66 | if (service.toLowerCase() === 'samsungtvplus') { 67 | const samsungOutput = await handleSamsungTVPlus(region, sort); 68 | res.writeHead(200, { 69 | 'Content-Type': 'text/plain' 70 | }); 71 | return res.end(samsungOutput); 72 | } 73 | 74 | // Handle Roku service 75 | if (service.toLowerCase() === 'roku') { 76 | const rokuOutput = await handleRoku(sort); 77 | res.writeHead(200, { 78 | 'Content-Type': 'text/plain' 79 | }); 80 | return res.end(rokuOutput); 81 | } 82 | 83 | // Handle Stirr service 84 | if (service.toLowerCase() === 'stirr') { 85 | const stirrOutput = await handleStirr(sort); 86 | res.writeHead(200, { 87 | 'Content-Type': 'text/plain' 88 | }); 89 | return res.end(stirrOutput); 90 | } 91 | 92 | // Handle Tubi service 93 | if (service.toLowerCase() === 'tubi') { 94 | const tubiOutput = await handleTubi(); 95 | res.writeHead(200, { 96 | 'Content-Type': 'text/plain' 97 | }); 98 | return res.end(tubiOutput); 99 | } 100 | 101 | // Handle PBS service 102 | if (service.toLowerCase() === 'pbs') { 103 | const pbsOutput = await handlePBS(); 104 | res.writeHead(200, { 105 | 'Content-Type': 'text/plain' 106 | }); 107 | return res.end(pbsOutput); 108 | } 109 | 110 | // Handle PBSKids service 111 | if (service.toLowerCase() === 'pbskids') { 112 | const pbsKidsOutput = await handlePBSKids(); 113 | res.writeHead(200, { 114 | 'Content-Type': 'text/plain' 115 | }); 116 | return res.end(pbsKidsOutput); 117 | } 118 | 119 | // If no matching service was found, send an error response 120 | res.writeHead(400, { 121 | 'Content-Type': 'text/plain' 122 | }); 123 | return res.end('Error: Unsupported service type provided'); 124 | 125 | }); 126 | 127 | //------ Service Functions ------// 128 | 129 | // Function to handle the PlutoTV service 130 | async function handlePlutoTV(region, sort) { 131 | const PLUTO_URL = 'https://i.mjh.nz/PlutoTV/.channels.json.gz'; 132 | const STREAM_URL_TEMPLATE = 'https://jmp2.uk/plu-{id}.m3u8'; 133 | const regionNameMap = { 134 | ar: "Argentina", 135 | br: "Brazil", 136 | ca: "Canada", 137 | cl: "Chile", 138 | de: "Germany", 139 | dk: "Denmark", 140 | es: "Spain", 141 | fr: "France", 142 | gb: "United Kingdom", 143 | it: "Italy", 144 | mx: "Mexico", 145 | no: "Norway", 146 | se: "Sweden", 147 | us: "United States" 148 | }; 149 | 150 | try { 151 | const data = await fetchGzippedJson(PLUTO_URL); 152 | let output = `#EXTM3U url-tvg="https://github.com/matthuisman/i.mjh.nz/raw/master/PlutoTV/${region}.xml.gz"\n`; 153 | let channels = {}; 154 | 155 | if (region === 'all') { 156 | for (const regionKey in data.regions) { 157 | const regionData = data.regions[regionKey]; 158 | const regionFullName = regionNameMap[regionKey] || regionKey.toUpperCase(); 159 | for (const channelKey in regionData.channels) { 160 | const channel = { ...regionData.channels[channelKey], region: regionFullName }; 161 | const uniqueChannelId = `${channelKey}-${regionKey}`; 162 | channels[uniqueChannelId] = channel; 163 | } 164 | } 165 | } else { 166 | if (!data.regions[region]) { 167 | return `Error: Region '${region}' not found in Pluto data`; 168 | } 169 | channels = data.regions[region].channels || {}; 170 | } 171 | 172 | const sortedChannelIds = Object.keys(channels).sort((a, b) => { 173 | const channelA = channels[a]; 174 | const channelB = channels[b]; 175 | return sort === 'chno' ? (channelA.chno - channelB.chno) : channelA.name.localeCompare(channelB.name); 176 | }); 177 | 178 | sortedChannelIds.forEach(channelId => { 179 | const channel = channels[channelId]; 180 | const { chno, name, group, logo, region: channelRegion } = channel; 181 | const groupTitle = region === 'all' ? `${channelRegion}` : group; 182 | 183 | output += `#EXTINF:-1 channel-id="${channelId}" tvg-id="${channelId}" tvg-chno="${chno}" tvg-name="${name}" tvg-logo="${logo}" group-title="${groupTitle}", ${name}\n`; 184 | output += STREAM_URL_TEMPLATE.replace('{id}', channelId.split('-')[0]) + '\n'; 185 | }); 186 | 187 | output = output.replace(/tvg-id="(.*?)-\w{2}"/g, 'tvg-id="$1"'); 188 | return output; 189 | } catch (error) { 190 | console.error('Error fetching Pluto TV data:', error.message); 191 | return 'Error fetching Pluto data: ' + error.message; 192 | } 193 | } 194 | 195 | // Function to handle the Plex service 196 | async function handlePlex(region, sort) { 197 | const PLEX_URL = 'https://i.mjh.nz/Plex/.channels.json.gz'; 198 | const CHANNELS_JSON_URL = 'https://raw.githubusercontent.com/dtankdempse/free-iptv-channels/main/plex/channels.json'; 199 | const STREAM_URL_TEMPLATE = 'https://jmp2.uk/plex-{id}.m3u8'; 200 | 201 | sort = sort || 'name'; 202 | let output = `#EXTM3U url-tvg="https://github.com/matthuisman/i.mjh.nz/raw/master/Plex/${region}.xml.gz"\n`; 203 | const regionNameMap = { 204 | us: "United States", 205 | mx: "Mexico", 206 | es: "Spain", 207 | ca: "Canada", 208 | au: "Australia", 209 | nz: "New Zealand" 210 | }; 211 | 212 | try { 213 | // Fetch the Plex data 214 | console.log('Fetching new Plex data from URL:', PLEX_URL); 215 | const data = await fetchGzippedJson(PLEX_URL); 216 | 217 | console.log('Fetching new channels.json data from URL:', CHANNELS_JSON_URL); 218 | const plexChannels = await fetchJson(CHANNELS_JSON_URL); 219 | 220 | let channels = {}; 221 | 222 | // Process channels based on region 223 | if (region === 'all') { 224 | for (const regionKey in data.regions) { 225 | const regionData = data.regions[regionKey]; 226 | const regionFullName = regionNameMap[regionKey] || regionKey.toUpperCase(); 227 | 228 | for (const channelKey in data.channels) { 229 | const channel = data.channels[channelKey]; 230 | if (channel.regions.includes(regionKey)) { 231 | const uniqueChannelId = `${channelKey}-${regionKey}`; 232 | channels[uniqueChannelId] = { 233 | ...channel, 234 | region: regionFullName, 235 | group: regionFullName, 236 | originalId: channelKey 237 | }; 238 | } 239 | } 240 | } 241 | } else { 242 | if (!data.regions[region]) { 243 | throw new Error(`Error: Region '${region}' not found in Plex data.`); 244 | } 245 | for (const channelKey in data.channels) { 246 | const channel = data.channels[channelKey]; 247 | if (channel.regions.includes(region)) { 248 | const matchingChannel = plexChannels.find(ch => ch.Title === channel.name); 249 | const genre = matchingChannel && matchingChannel.Genre ? matchingChannel.Genre : 'Uncategorized'; 250 | channels[channelKey] = { 251 | ...channel, 252 | group: genre, 253 | originalId: channelKey 254 | }; 255 | } 256 | } 257 | } 258 | 259 | // Sort channels based on the specified sorting criteria 260 | const sortedChannelIds = Object.keys(channels).sort((a, b) => { 261 | const channelA = channels[a]; 262 | const channelB = channels[b]; 263 | return sort === 'chno' ? (channelA.chno - channelB.chno) : channelA.name.localeCompare(channelB.name); 264 | }); 265 | 266 | sortedChannelIds.forEach(channelId => { 267 | const channel = channels[channelId]; 268 | const { 269 | chno, 270 | name, 271 | logo, 272 | group, 273 | originalId 274 | } = channel; 275 | 276 | output += `#EXTINF:-1 channel-id="${channelId}" tvg-id="${channelId}" tvg-chno="${chno || ''}" tvg-name="${name}" tvg-logo="${logo}" group-title="${group}", ${name}\n`; 277 | output += STREAM_URL_TEMPLATE.replace('{id}', originalId) + '\n'; 278 | }); 279 | 280 | output = output.replace(/tvg-id="(.*?)-\w{2}"/g, 'tvg-id="$1"'); 281 | return output; 282 | 283 | } catch (error) { 284 | console.error('Error fetching Plex or channels data:', error.message); 285 | return `Error: ${error.message}`; 286 | } 287 | } 288 | 289 | // Function to handle the Samsung TV Plus service 290 | async function handleSamsungTVPlus(region, sort) { 291 | const SAMSUNG_URL = 'https://i.mjh.nz/SamsungTVPlus/.channels.json.gz'; 292 | const STREAM_URL_TEMPLATE = 'https://jmp2.uk/sam-{id}.m3u8'; 293 | 294 | sort = sort || 'name'; 295 | 296 | try { 297 | console.log('Fetching new SamsungTVPlus data from URL:', SAMSUNG_URL); 298 | const data = await fetchGzippedJson(SAMSUNG_URL); // Assume fetchJson is a predefined async function 299 | 300 | let output = `#EXTM3U url-tvg="https://github.com/matthuisman/i.mjh.nz/raw/master/SamsungTVPlus/${region}.xml.gz"\n`; 301 | let channels = {}; 302 | 303 | // If "all" is specified, gather channels from each region 304 | if (region === 'all') { 305 | for (const regionKey in data.regions) { 306 | const regionData = data.regions[regionKey]; 307 | const regionFullName = regionData.name || regionKey.toUpperCase(); 308 | for (const channelKey in regionData.channels) { 309 | const channel = { 310 | ...regionData.channels[channelKey], 311 | region: regionFullName 312 | }; 313 | const uniqueChannelId = `${channelKey}-${regionKey}`; 314 | channels[uniqueChannelId] = channel; 315 | } 316 | } 317 | } else { 318 | // Handle a single specified region 319 | if (!data.regions[region]) { 320 | throw new Error(`Error: Region '${region}' not found in SamsungTVPlus data.`); 321 | } 322 | channels = data.regions[region].channels || {}; 323 | } 324 | 325 | // Sort channels based on the specified sorting criteria 326 | const sortedChannelIds = Object.keys(channels).sort((a, b) => { 327 | const channelA = channels[a]; 328 | const channelB = channels[b]; 329 | return sort === 'chno' ? (channelA.chno - channelB.chno) : channelA.name.localeCompare(channelB.name); 330 | }); 331 | 332 | sortedChannelIds.forEach(channelId => { 333 | const channel = channels[channelId]; 334 | const { 335 | chno, 336 | name, 337 | group, 338 | logo, 339 | region: channelRegion 340 | } = channel; 341 | const groupTitle = region === 'all' ? `${channelRegion}` : group; 342 | 343 | output += `#EXTINF:-1 channel-id="${channelId}" tvg-id="${channelId}" tvg-chno="${chno}" tvg-name="${name}" tvg-logo="${logo}" group-title="${groupTitle}", ${name}\n`; 344 | output += STREAM_URL_TEMPLATE.replace('{id}', channelId.split('-')[0]) + '\n'; 345 | }); 346 | 347 | output = output.replace(/tvg-id="(.*?)-\w{2}"/g, 'tvg-id="$1"'); 348 | return output; 349 | 350 | } catch (error) { 351 | console.error('Error fetching SamsungTVPlus data:', error.message); 352 | return `Error fetching SamsungTVPlus data: ${error.message}`; 353 | } 354 | } 355 | 356 | // Function to handle the Roku service 357 | async function handleRoku(sort) { 358 | const ROKU_URL = 'https://i.mjh.nz/Roku/.channels.json.gz'; 359 | const STREAM_URL_TEMPLATE = 'https://jmp2.uk/rok-{id}.m3u8'; 360 | 361 | // Set a default for `sort` if not provided 362 | sort = sort || 'name'; 363 | 364 | try { 365 | console.log('Fetching new Roku data from URL:', ROKU_URL); 366 | const data = await fetchGzippedJson(ROKU_URL); // Assume fetchJson is a predefined async function 367 | 368 | let output = `#EXTM3U url-tvg="https://github.com/matthuisman/i.mjh.nz/raw/master/Roku/all.xml.gz"\n`; 369 | const channels = data.channels || {}; 370 | 371 | // Sort channels based on the specified sorting criteria 372 | const sortedChannelIds = Object.keys(channels).sort((a, b) => { 373 | const channelA = channels[a]; 374 | const channelB = channels[b]; 375 | return sort === 'chno' ? (channelA.chno - channelB.chno) : channelA.name.localeCompare(channelB.name); 376 | }); 377 | 378 | sortedChannelIds.forEach(channelId => { 379 | const channel = channels[channelId]; 380 | const { 381 | chno, 382 | name, 383 | groups, 384 | logo 385 | } = channel; 386 | 387 | // Use the first group in `groups` array for `group-title` 388 | const groupTitle = groups && groups.length > 0 ? groups[0] : 'Uncategorized'; 389 | 390 | output += `#EXTINF:-1 channel-id="${channelId}" tvg-id="${channelId}" tvg-chno="${chno}" tvg-name="${name}" tvg-logo="${logo}" group-title="${groupTitle}", ${name}\n`; 391 | output += STREAM_URL_TEMPLATE.replace('{id}', channelId) + '\n'; 392 | }); 393 | 394 | return output; 395 | 396 | } catch (error) { 397 | console.error('Error fetching Roku data:', error.message); 398 | return `Error fetching Roku data: ${error.message}`; 399 | } 400 | } 401 | 402 | // Function to handle the Stirr service 403 | async function handleStirr(sort) { 404 | const STIRR_URL = 'https://i.mjh.nz/Stirr/.channels.json.gz'; 405 | 406 | // Set a default for `sort` if not provided 407 | sort = sort || 'name'; 408 | 409 | try { 410 | console.log('Fetching new Stirr data from URL:', STIRR_URL); 411 | const data = await fetchGzippedJson(STIRR_URL); // Assume fetchJson is a predefined async function 412 | 413 | let output = `#EXTM3U url-tvg="https://i.mjh.nz/Stirr/all.xml.gz"\n`; 414 | const channels = data.channels || {}; 415 | 416 | // Sort channels based on the specified sorting criteria 417 | const sortedChannelIds = Object.keys(channels).sort((a, b) => { 418 | const channelA = channels[a]; 419 | const channelB = channels[b]; 420 | return sort === 'chno' ? (channelA.chno - channelB.chno) : channelA.name.localeCompare(channelB.name); 421 | }); 422 | 423 | sortedChannelIds.forEach(channelId => { 424 | const channel = channels[channelId]; 425 | const { 426 | chno, 427 | name, 428 | groups, 429 | logo 430 | } = channel; 431 | 432 | // Concatenate all groups, separated by commas 433 | const groupTitle = groups && groups.length > 0 ? groups.join(', ') : 'Uncategorized'; 434 | 435 | // Generate the stream URL using the template 436 | const streamUrl = `https://jmp2.uk/str-${channelId}.m3u8`; 437 | 438 | output += `#EXTINF:-1 channel-id="${channelId}" tvg-id="${channelId}" tvg-chno="${chno}" tvg-name="${name}" tvg-logo="${logo}" group-title="${groupTitle}", ${name}\n`; 439 | output += `${streamUrl}\n`; 440 | }); 441 | 442 | return output; 443 | 444 | } catch (error) { 445 | console.error('Error fetching Stirr data:', error.message); 446 | return `Error fetching Stirr data: ${error.message}`; 447 | } 448 | } 449 | 450 | // Function to handle the Tubi service 451 | async function handleTubi() { 452 | const playlistUrl = 'https://github.com/dtankdempse/tubi-m3u/raw/refs/heads/main/tubi_playlist_us.m3u'; 453 | const epgUrl = 'https://raw.githubusercontent.com/dtankdempse/tubi-m3u/refs/heads/main/tubi_epg_us.xml'; 454 | 455 | return new Promise((resolve, reject) => { 456 | try { 457 | https.get(playlistUrl, (res) => { 458 | let data = ''; 459 | 460 | if (res.statusCode === 302 && res.headers.location) { 461 | https.get(res.headers.location, (redirectRes) => { 462 | let redirectData = ''; 463 | 464 | redirectRes.on('data', (chunk) => { 465 | redirectData += chunk; 466 | }); 467 | 468 | redirectRes.on('end', () => { 469 | try { 470 | if (redirectData.trim() === '') { 471 | reject('Playlist data is empty.'); 472 | return; 473 | } 474 | let output = ''; 475 | output += redirectData; 476 | resolve(output); 477 | } catch (error) { 478 | reject(`Error processing Tubi data: ${error.message}`); 479 | } 480 | }); 481 | }).on('error', (error) => { 482 | reject(`Error fetching redirected Tubi data: ${error.message}`); 483 | }); 484 | return; 485 | } 486 | 487 | if (res.statusCode !== 200) { 488 | reject(`Failed to fetch playlist. Status code: ${res.statusCode}`); 489 | return; 490 | } 491 | 492 | res.on('data', (chunk) => { 493 | data += chunk; 494 | }); 495 | 496 | res.on('end', () => { 497 | try { 498 | if (data.trim() === '') { 499 | reject('Playlist data is empty.'); 500 | return; 501 | } 502 | let output = `#EXTM3U url-tvg="${epgUrl}" 503 | `; 504 | output += data; 505 | resolve(output); 506 | } catch (error) { 507 | reject(`Error processing Tubi data: ${error.message}`); 508 | } 509 | }); 510 | }).on('error', (error) => { 511 | reject(`Error fetching Tubi data: ${error.message}`); 512 | }); 513 | } catch (error) { 514 | reject(`Unexpected error: ${error.message}`); 515 | } 516 | }); 517 | } 518 | 519 | // Function to handle the PBS Kids service 520 | async function handlePBSKids() { 521 | const APP_URL = 'https://i.mjh.nz/PBS/.kids_app.json.gz'; 522 | const EPG_URL = 'https://github.com/matthuisman/i.mjh.nz/raw/master/PBS/kids_all.xml.gz'; 523 | 524 | try { 525 | const data = await fetchGzippedJson(APP_URL); 526 | let output = `#EXTM3U url-tvg="${EPG_URL}"\n`; 527 | 528 | const sortedKeys = Object.keys(data.channels).sort((a, b) => { 529 | return data.channels[a].name.toLowerCase().localeCompare(data.channels[b].name.toLowerCase()); 530 | }); 531 | 532 | sortedKeys.forEach(key => { 533 | const channel = data.channels[key]; 534 | output += `#EXTINF:-1 channel-id="pbskids-${key}" tvg-name="${channel.name}" tvg-id="${key}" tvg-logo="${channel.logo}", ${channel.name}\n${channel.url}\n`; 535 | }); 536 | 537 | return output; 538 | } catch (error) { 539 | return 'Error fetching PBS Kids data: ' + error.message; 540 | } 541 | } 542 | 543 | // Function to handle the PBS service 544 | async function handlePBS() { 545 | const DATA_URL = 'https://i.mjh.nz/PBS/.app.json.gz'; 546 | const EPG_URL = 'https://i.mjh.nz/PBS/all.xml.gz'; 547 | 548 | try { 549 | console.log('Fetching new PBS data from URL:', DATA_URL); 550 | const data = await fetchGzippedJson(DATA_URL); // Assume fetchJson is a predefined async function 551 | 552 | // Format data for M3U8 553 | let output = `#EXTM3U x-tvg-url="${EPG_URL}"\n`; 554 | 555 | Object.keys(data.channels).forEach(key => { 556 | const channel = data.channels[key]; 557 | output += `#EXTINF:-1 channel-id="pbs-${key}" tvg-id="${key}" tvg-name="${channel.name}" tvg-logo="${channel.logo}", ${channel.name}\n`; 558 | output += `#KODIPROP:inputstream.adaptive.manifest_type=mpd\n`; 559 | output += `#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha\n`; 560 | output += `#KODIPROP:inputstream.adaptive.license_key=${channel.license}|Content-Type=application%2Foctet-stream&user-agent=okhttp%2F4.9.0|R{SSM}|\n`; 561 | output += `${channel.url}|user-agent=okhttp%2F4.9.0\n`; 562 | }); 563 | 564 | return output; 565 | 566 | } catch (error) { 567 | console.error('Error fetching PBS data:', error.message); 568 | return `Error fetching PBS data: ${error.message}`; 569 | } 570 | } 571 | 572 | //------ Other Functions ------// 573 | 574 | // Fetch JSON data from the provided URL 575 | function fetchJson(url) { 576 | return new Promise((resolve, reject) => { 577 | https.get(url, (res) => { 578 | let data = ''; 579 | 580 | res.on('data', (chunk) => { 581 | data += chunk; 582 | }); 583 | 584 | res.on('end', () => { 585 | try { 586 | resolve(JSON.parse(data)); 587 | } catch (error) { 588 | reject(error); 589 | } 590 | }); 591 | }).on('error', (error) => { 592 | reject(error); 593 | }); 594 | }); 595 | } 596 | 597 | // Function to handle the home page request 598 | function handleHomePage(res) { 599 | // Serve the HTML content for the home page 600 | res.writeHead(200, { 601 | 'Content-Type': 'text/html' 602 | }); 603 | res.end(` 604 | 605 | 606 | 607 | 608 | Multi-Service M3U8 Playlist 609 | 641 | 642 | 643 |
644 |

Multi-Service M3U8 Playlist

645 |

Script Access URL

646 |

647 | Use the following URL to access the hosted script. Replace the 648 | ADD_REGION and 649 | ADD_SERVICE placeholders with your desired values: 650 |

651 |
652 |

653 | After customizing the URL by replacing the ADD_REGION and ADD_SERVICE placeholders (e.g., \`us\` for the US region and \`PlutoTV\` for the service), copy the complete URL and paste it into the "Add Playlist" or "M3U8 URL" section of your IPTV application. Once added, the app will load both the channels and the guide information. 654 |

655 | 656 |

Available Service Parameter Values

657 |
    658 |
  • Plex
  • 659 |
  • Roku
  • 660 |
  • SamsungTVPlus
  • 661 |
  • PlutoTV
  • 662 |
  • PBS
  • 663 |
  • PBSKids
  • 664 |
  • Stirr
  • 665 |
  • Tubi
  • 666 |
667 | 668 |

Available Region Parameter Values

669 |
    670 |
  • all (for all regions)
  • 671 |
  • ar (Argentina)
  • 672 |
  • br (Brazil)
  • 673 |
  • ca (Canada)
  • 674 |
  • cl (Chile)
  • 675 |
  • de (Germany)
  • 676 |
  • dk (Denmark)
  • 677 |
  • es (Spain)
  • 678 |
  • fr (France)
  • 679 |
  • gb (United Kingdom)
  • 680 |
  • mx (Mexico)
  • 681 |
  • no (Norway)
  • 682 |
  • se (Sweden)
  • 683 |
  • us (United States)
  • 684 |
685 | 686 |

Available Sorting Parameter Values (optional)

687 |
    688 |
  • name (default): Sorts the channels alphabetically by their name.
  • 689 |
  • chno: Sorts the channels by their assigned channel number.
  • 690 |
691 | 692 | 693 |

EPG for TV Guide Information

694 | 695 |

The EPG URLs are embedded directly within the playlists. If you'd prefer to manually add the EPG guide, you can find the relevant URLs for each service on this page.

696 | 697 |

Disclaimer:

698 | 699 |

This repository has no control over the streams, links, or the legality of the content provided by Pluto, Samsung, Stirr, Tubi, Plex, PBS, and Roku. Additionally, this script simply converts the JSON files provided by i.mjh.nz into an M3U8 playlist. It is the end user's responsibility to ensure the legal use of these streams. We strongly recommend verifying that the content complies with the laws and regulations of your country before use.

700 | 701 | 704 |
705 | 711 | 712 | `); 713 | } 714 | 715 | // function to fetch and decompresses gzipped JSON files 716 | async function fetchGzippedJson(url, maxRedirects = 5) { 717 | return new Promise((resolve, reject) => { 718 | const makeRequest = (currentUrl, redirectsRemaining) => { 719 | https.get(currentUrl, (response) => { 720 | if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { 721 | if (redirectsRemaining <= 0) { 722 | reject(new Error('Too many redirects')); 723 | return; 724 | } 725 | const redirectUrl = new URL(response.headers.location, currentUrl).href; 726 | makeRequest(redirectUrl, redirectsRemaining - 1); 727 | return; 728 | } 729 | 730 | if (response.statusCode !== 200) { 731 | reject(new Error(`Request failed. Status code: ${response.statusCode}`)); 732 | return; 733 | } 734 | 735 | const gunzip = zlib.createGunzip(); 736 | const chunks = []; 737 | response.pipe(gunzip); 738 | 739 | gunzip.on('data', (chunk) => chunks.push(chunk)); 740 | gunzip.on('end', () => { 741 | try { 742 | resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); 743 | } catch (error) { 744 | reject(new Error('Failed to parse JSON: ' + error.message)); 745 | } 746 | }); 747 | gunzip.on('error', (error) => reject(error)); 748 | }).on('error', (error) => reject(error)); 749 | }; 750 | 751 | makeRequest(url, maxRedirects); 752 | }); 753 | } 754 | 755 | server.listen(port, hostname, () => { 756 | console.log('Server running at http://localhost:4242/'); 757 | }); 758 | --------------------------------------------------------------------------------