The latest version is this one:
#!/usr/bin/python
import requests
from datetime import datetime, timedelta, timezone
import xml.etree.ElementTree as ET
import socket
import io
import sys
# CONFIG #############################################################################################
SOCKET = "/config/epggrab/xmltv.sock"  # Path to your UNIX socket
######################################################################################################
if len(sys.argv) > 1:
    IP = sys.argv[1]
else:
    IP = None
now = datetime.now(timezone.utc)
start_time = now - timedelta(
    minutes=now.minute % 30, seconds=now.second, microseconds=now.microsecond
)
stop_time = start_time + timedelta(hours=8)
epg_begin = start_time.strftime("%Y-%m-%dT%H:%M:%S-00:00")
epg_end = stop_time.strftime("%Y-%m-%dT%H:%M:%S-00:00")
if IP != None:
    headers={"X-Forwarded-For": IP}
else:
    headers={}
data = requests.get(
    f"https://api.pluto.tv/v2/channels?start={epg_begin}&stop={epg_end}", headers=headers
).json()
m3u = "#EXTM3U\n"
root = ET.Element("tv")
programmes = []
for channel in data:
    channel_element = ET.SubElement(root, "channel", id="pluto-" + channel["_id"])
    ET.SubElement(channel_element, "display-name").text = channel["name"]
    ET.SubElement(channel_element, "channel-number").text = str(channel["number"])
    ET.SubElement(channel_element, "icon").text = channel.get("colorLogoPNG", {}).get(
        "path", ""
    )
    m3u += f'#EXTINF:-1 tvg-id="{"pluto-" + channel["_id"]}" tvg-chno="{channel["number"]}" tvg-name="{channel["name"]}" tvg-logo="{channel.get("colorLogoPNG", {}).get("path", "")}",{channel["name"]}\n'
    if IP != None:
        m3u += f"pipe://streamlink --http-header 'x-forwarded-for={IP}' 'https://pluto.tv/live-tv/{channel['_id']}' best --stdout\n"
    else:
        m3u += f"pipe://streamlink 'https://pluto.tv/live-tv/{channel['_id']}' best --stdout\n"
    for programme in channel["timelines"]:
        programme_data = {
            "start": programme["start"],
            "stop": programme["stop"],
            "channel": "pluto-" + channel["_id"],
            "title": programme["title"],
            "episode": programme.get("episode", {}),
        }
        programmes.append(programme_data)
for programme in programmes:
    start_time = datetime.fromisoformat(programme["start"]).strftime(
        "%Y%m%d%H%M%S +0000"
    )
    stop_time = datetime.fromisoformat(programme["stop"]).strftime("%Y%m%d%H%M%S +0000")
    programme_element = ET.SubElement(
        root,
        "programme",
        start=start_time,
        stop=stop_time,
        channel=programme["channel"],
    )
    ET.SubElement(programme_element, "title").text = programme.get("title", "Untitled")
    ET.SubElement(programme_element, "sub-title").text = programme["episode"].get(
        "name", ""
    )
    ET.SubElement(programme_element, "desc").text = programme["episode"].get(
        "description", ""
    )
    ET.SubElement(programme_element, "episode-num").text = (
        f'S{programme["episode"].get("season", 0)}E{programme["episode"].get("number", 0)}'
    )
    ET.SubElement(programme_element, "category").text = programme["episode"].get(
        "genre", ""
    )
    ET.SubElement(programme_element, "category").text = programme["episode"].get(
        "subGenre", ""
    )
    ET.SubElement(programme_element, "category").text = programme.get("category", "")
tree = ET.ElementTree(root)
ET.indent(tree, space="\t", level=0)
xml_bytes = io.BytesIO()
tree.write(xml_bytes, encoding="UTF-8", xml_declaration=True)
try:
    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
        s.connect(SOCKET)
        xml_bytes.seek(0)
        s.sendall(xml_bytes.read())
except:
    pass
    with open("pluto.xmltv", "wb") as file:
        tree.write(file, encoding="utf-8", xml_declaration=True)
print(m3u)
It works on a Ubuntu host running the Linuxserver.io Alpine based docker image.
From memory, the quotes are needed on my system.
The EPG maps fine too.