├── .gitignore ├── Dockerfile ├── LICENSE ├── docker-compose.yml ├── readme.md ├── zap2it-GuideScrape.py └── zap2itconfig.ini.dist /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | zap2itconfig.ini 3 | xmlguide.xmltv 4 | *.xmltv 5 | *.swp 6 | zap2itconfig.ini.bck -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:latest 2 | 3 | WORKDIR /guide 4 | COPY ./zap2it-GuideScrape.py /guide 5 | COPY ./zap2itconfig.ini /guide 6 | CMD ["python","./zap2it-GuideScrape.py","-w"] 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2021 Daniel Widrick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | guide-scraper: 4 | build: . 5 | container_name: guide-scraper 6 | ports: 7 | - "9000:9000" 8 | environment: 9 | - TZ=America/New_York 10 | restart: always -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | This script is designed to take TV listings from zap2it and convert them to xmltv for use with applications such as Jellyfin/Emby. 3 | 4 | ``` 5 | $ python3 zap2it-GuideScrape.py -h 6 | usage: Parse Zap2it Guide into XMLTV [-h] [-c CONFIGFILE] [-o OUTPUTFILE] [-l LANGUAGE] [-f] [-C] [-w] 7 | 8 | options: 9 | -h, --help show this help message and exit 10 | -c CONFIGFILE, --configfile CONFIGFILE, -i CONFIGFILE, --ifile CONFIGFILE 11 | Path to config file 12 | -o OUTPUTFILE, --outputfile OUTPUTFILE, --ofile OUTPUTFILE 13 | Path to output file 14 | -l LANGUAGE, --language LANGUAGE 15 | Language 16 | -f, --findid Find Headendid / lineupid 17 | -C, --channels List available channels 18 | -w, --web Start a webserver at http://localhost:9000 to serve /xmlguide.xmltv 19 | ``` 20 | 21 | ## 18-OCT-2021 22 | Please note that --ofile, --ifile, and -i arguments may be deprecated and removed in a future release. Please use -c, --configfile, and -o, --outputfile accordingly. 23 | 24 | ## 31-AUG-2022 25 | Added the -f flag to assist with finding the headendId and lineupId for various providers. 26 | Added an optional [lineup] section to the config to accomodate loading data for non-OTA providers 27 | The script will attempt to derive the lineupId from data available, but the headendId is buried deeper and must be set manually if changing providers. 28 | The 'device' field has also been added to the [lineup] config section and is supported in the script 29 |
 30 |     type           |name                                    |location       |headendID      |lineupID                 |device         
 31 |     OTA            |Local Over the Air Broadcast            |               |lineupId       |USA-lineupId-DEFAULT     |               
 32 |     CABLE          |Xfinity - Digital                       |Daly City      |CA55528        |USA-CA55528-DEFAULT      |X              
 33 |     SATELLITE      |DISH San Francisco - Satellite          |San Francisco  |DISH807        |USA-DISH807-DEFAULT      |-              
 34 |     CABLE          |AT&T U-verse TV - Digital               |San Francisco  |CA66343        |USA-CA66343-DEFAULT      |X              
 35 | 
36 | 37 | ## 07-NOV-2022 38 | Docker isn't my strongest area so I'm not sure of the exact usecase, but I've created a VERY basic Dockerfile 39 | Basic Docker Support: 40 | Run the following commands from the root of this repo in Windows(PowerShell) or linux: 41 |
 42 | docker build -t zap2it:latest .
 43 | docker run -v ${PWD}:/guide zap2it
 44 | 
45 | Running the script like this will read zap2itconfig.ini from the host current directory and output the .xmltv files to the host current directory. 46 | 47 | ## 09-MAR-2025 48 | ### Multiple Zipcodes 49 | Added support for multiple zipcodes. zap2itconfig.ini now supports listing multiple zip codes and should deduplicate the resulting guide with consideration to overlapping channels: 50 | ``` 51 | zipCode: [55555, 44444] 52 | ``` 53 | 54 | Single zip codes are still supporting using the old format: 55 | ``` 56 | zipCode: 55555 57 | ``` 58 | 59 | or a single entry in the new json format: 60 | ``` 61 | [55555] 62 | ``` 63 | 64 | ### Channel Filtering 65 | Added support for channel filtering via `favoriteChannels:` in config. If this value is populated, only channel IDs listed in the config will be listed. Example: 66 | ``` 67 | favoriteChannels: [53158,42578] 68 | ``` 69 | 70 | ### Web based guide 71 | Added a docker-compose.yml file that will run a webserver at `0.0.0.0:9000` and serve `/xmlguide.xmltv` while updating the guide in the background every 24 hours. 72 | 73 | This allows Jellyfin to point at the url and automatically receive guide updates with no further scripting. 74 | 75 | ## 02-JUN-2025 76 | ### Environment variable config 77 | All values in the zap2itconfig.ini can now be passed as environment variables via docker compose (or in the normal env). Variables take the form: 78 | `ZAP2IT_SECITON_KEY` 79 | 80 | Variables passed from the environment take precedence. Then variables from the zap2itconfig.ini. Then any hard coded defaults int he script. 81 | 82 | EG: 83 | ``` 84 | services: 85 | guide-scraper-dtv: 86 | build: . 87 | container_name: guide-scraper-dtv 88 | ports: 89 | - "9000:9000" 90 | environment: 91 | - TZ=America/New_York 92 | - ZAP2IT_PREFS_LANG=es 93 | - ZAP2IT_PREFS_COUNTRY=US 94 | - ZAP2IT_CRED_USERNAME=your_username 95 | - ZAP2IT_CRED_PASSWORD=your_password 96 | - ZAP2IT_LINEUP_LINEUPID=USA-DITV528-DEFAULT 97 | - ZAP2IT_LINEUP_HEADENDID=DITV528 98 | guide-scraper-ota: 99 | build: . 100 | container_name: guide-scraper-ota 101 | ports: 102 | - "9001:9000" 103 | environment: 104 | - TZ=America/New_York 105 | - ZAP2IT_PREFS_LANG=es 106 | - ZAP2IT_PREFS_COUNTRY=US 107 | - ZAP2IT_CRED_USERNAME=your_username 108 | - ZAP2IT_CRED_PASSWORD=your_password 109 | - ZAP2IT_LINEUP_LINEUPID=DFLT 110 | - ZAP2IT_LINEUP_HEADENDID=lineupId 111 | restart: always 112 | ``` -------------------------------------------------------------------------------- /zap2it-GuideScrape.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import json 3 | import urllib.parse, urllib.request, urllib.error 4 | import time, datetime 5 | import xml.dom.minidom 6 | import sys, os, argparse 7 | 8 | #Use Globals to track state of the guide 9 | ADDED_CHANNELS = [] 10 | ADDED_EVENTS = [] 11 | 12 | class Zap2ItGuideScrape(): 13 | 14 | def __init__(self,configLocation="./zap2itconfig.ini",outputFile="xmlguide.xmltv"): 15 | self.confLocation = configLocation 16 | self.outputFile=outputFile 17 | if not os.path.exists(self.confLocation): 18 | print("Error: " + self.confLocation + " does not exist.") 19 | print("Copy config.ini.dist to config.ini and update the settings to match your zap2it account") 20 | exit(1) 21 | print("Loading config: ", self.confLocation, " and outputting: ", outputFile) 22 | 23 | self.config = configparser.ConfigParser() 24 | config = self.config.read(self.confLocation) 25 | if config == []: 26 | print("Failed to read config: " + self.confLocation) 27 | print("Check file permissions") 28 | exit(1) 29 | # Use get_config_value for all config lookups 30 | self.lang = self.get_config_value("prefs","lang", fallback="en") 31 | 32 | self.zapToken = "" 33 | def get_config_value(self, section, key, fallback=None): 34 | # Environment variable name: ZAP2IT_SECTION_KEY 35 | env_var = f"ZAP2IT_{section.upper()}_{key.upper()}" 36 | print(f"Checking for environment variable: {env_var}") 37 | if env_var in os.environ: 38 | print(f"Using environment variable {env_var} for {section}.{key}") 39 | return os.environ[env_var] 40 | print(f"Using config.ini value for {section}.{key}") 41 | return self.config.get(section, key, fallback=fallback) 42 | 43 | def BuildAuthRequest(self): 44 | url = "https://tvlistings.gracenote.com/api/user/login" 45 | parameters = { 46 | "emailid": self.get_config_value("creds","username"), 47 | "password": self.get_config_value("creds","password"), 48 | "isfacebookuser": "false", 49 | "usertype": 0, 50 | "objectid": "" 51 | } 52 | data = urllib.parse.urlencode((parameters)) 53 | data = data.encode('ascii') 54 | req = urllib.request.Request(url, data) 55 | return req 56 | def Authenticate(self): 57 | #Get token from login form 58 | authRequest = self.BuildAuthRequest() 59 | try: 60 | authResponse = urllib.request.urlopen(authRequest).read() 61 | except urllib.error.URLError as e: 62 | print("Error connecting to tvlistings.gracenote.com") 63 | print(e.reason) 64 | exit(1) 65 | authFormVars = json.loads(authResponse) 66 | self.zapTocken = authFormVars["token"] 67 | self.headendid= authFormVars["properties"]["2004"] 68 | def BuildIDRequest(self,zipCode): 69 | url = "https://tvlistings.gracenote.com/gapzap_webapi/api/Providers/getPostalCodeProviders/" 70 | url += self.get_config_value("prefs","country", fallback="us") + "/" 71 | url += zipCode + "/gapzap/" 72 | lang = self.get_config_value("prefs","lang", fallback="en-us") 73 | if lang != "": 74 | url += lang 75 | req = urllib.request.Request(url) 76 | return req 77 | def FindID(self,zipCode): 78 | idRequest = self.BuildIDRequest(zipCode) 79 | try: 80 | print("Loading provider ID data from: ",idRequest.full_url) 81 | idResponse = urllib.request.urlopen(idRequest).read() 82 | except urllib.error.URLError as e: 83 | print("Error loading provider IDs:") 84 | print(e.reason) 85 | exit(1) 86 | idVars = json.loads(idResponse) 87 | print(f'{"type":<15}|{"name":<40}|{"location":<15}|',end='') 88 | print(f'{"headendID":<15}|{"lineupId":<25}|{"device":<15}') 89 | for provider in idVars["Providers"]: 90 | print(f'{provider["type"]:<15}|',end='') 91 | print(f'{provider["name"]:<40}|',end='') 92 | print(f'{provider["location"]:<15}|',end='') 93 | print(f'{provider["headendId"]:<15}|',end='') 94 | print(f'{provider["lineupId"]:<25}|',end='') 95 | print(f'{provider["device"]:<15}') 96 | 97 | def BuildDataRequest(self,currentTime,zipCode): 98 | #Defaults 99 | lineupId = self.get_config_value("lineup","lineupId", fallback=self.headendid) 100 | headendId = self.get_config_value("lineup","headendId", 'lineupId') 101 | device = self.get_config_value("lineup","device", fallback='-') 102 | 103 | parameters = { 104 | 'Activity_ID': 1, 105 | 'FromPage': "TV%20Guide", 106 | 'AffiliateId': "gapzap", 107 | 'token': self.zapToken, 108 | 'aid': 'gapzap', 109 | 'lineupId': lineupId, 110 | 'timespan': 3, 111 | 'headendId': headendId, 112 | 'country': self.get_config_value("prefs", "country"), 113 | 'device': device, 114 | 'postalCode': zipCode, 115 | 'isOverride': "true", 116 | 'time': currentTime, 117 | 'pref': 'm,p', 118 | 'userId': '-' 119 | } 120 | data = urllib.parse.urlencode(parameters) 121 | url = "https://tvlistings.gracenote.com/api/grid?" + data 122 | req = urllib.request.Request(url) 123 | return req 124 | def GetData(self,time,zipCode): 125 | request = self.BuildDataRequest(time,zipCode) 126 | print("Load Guide for time: ",str(time)," :: ",zipCode) 127 | #print(request.full_url) 128 | response = urllib.request.urlopen(request).read() 129 | return json.loads(response) 130 | def AddChannelsToGuide(self, json): 131 | global ADDED_CHANNELS 132 | favoriteChannels = "" 133 | try: 134 | favoriteChannels = self.get_config_value("prefs","favoriteChannels", fallback="") 135 | except: 136 | pass 137 | for channel in json["channels"]: 138 | if favoriteChannels != "": 139 | if channel["channelId"] not in favoriteChannels: 140 | continue 141 | if channel["channelId"] in ADDED_CHANNELS: 142 | print("Duplicate Channel: ",channel["channelId"]) 143 | continue 144 | else: 145 | self.rootEl.appendChild(self.BuildChannelXML(channel)) 146 | ADDED_CHANNELS.append(channel["channelId"]) 147 | def AddEventsToGuide(self,json): 148 | dedup_count = 0 149 | global ADDED_EVENTS 150 | favoriteChannels = "" 151 | try: 152 | favoriteChannels = self.get_config_value("prefs","favoriteChannels", fallback="") 153 | if favoriteChannels == "": 154 | print("No favorite channels set, all channels will be included.") 155 | raise ValueError("No favorite channels set") #TODO: Pretty dirty 156 | except: 157 | pass 158 | for channel in json["channels"]: 159 | if favoriteChannels != "": 160 | if channel["channelId"] not in favoriteChannels: 161 | continue 162 | for event in channel["events"]: 163 | #Deduplicate json 164 | eventHash = hash(channel.get("channelId") + event.get("startTime") + event.get("endTime")) 165 | if eventHash not in ADDED_EVENTS: 166 | newChild = self.BuildEventXmL(event,channel["channelId"]) 167 | self.rootEl.appendChild(newChild) 168 | ADDED_EVENTS.append(eventHash) 169 | def BuildEventXmL(self,event,channelId): 170 | #preConfig 171 | season = "0" 172 | episode = "0" 173 | 174 | programEl = self.guideXML.createElement("programme") 175 | programEl.setAttribute("start",self.BuildXMLDate(event["startTime"])) 176 | programEl.setAttribute("stop",self.BuildXMLDate(event["endTime"])) 177 | programEl.setAttribute("channel",channelId) 178 | 179 | titleEl = self.guideXML.createElement("title") 180 | titleEl.setAttribute("lang",self.lang) #TODO: define 181 | titleTextEl = self.guideXML.createTextNode(event["program"]["title"]) 182 | titleEl.appendChild(titleTextEl) 183 | programEl.appendChild(titleEl) 184 | 185 | if event["program"]["episodeTitle"] is not None: 186 | subTitleEl = self.CreateElementWithData("sub-title",event["program"]["episodeTitle"]) 187 | subTitleEl.setAttribute("lang",self.lang) 188 | programEl.appendChild(subTitleEl) 189 | 190 | if event["program"]["shortDesc"] is None: 191 | event["program"]["shortDesc"] = "Unavailable" 192 | descriptionEl = self.CreateElementWithData("desc",event["program"]["shortDesc"]) 193 | descriptionEl.setAttribute("lang",self.lang) 194 | programEl.appendChild(descriptionEl) 195 | 196 | lengthEl = self.CreateElementWithData("length",event["duration"]) 197 | lengthEl.setAttribute("units","minutes") 198 | programEl.appendChild(lengthEl) 199 | 200 | if event["thumbnail"] is not None: 201 | thumbnailEl = self.CreateElementWithData("thumbnail","http://zap2it.tmsimg.com/assets/" + event["thumbnail"] + ".jpg") 202 | programEl.appendChild(thumbnailEl) 203 | iconEl = self.guideXML.createElement("icon") 204 | iconEl.setAttribute("src","http://zap2it.tmsimg.com/assets/" + event["thumbnail"] + ".jpg") 205 | programEl.appendChild(iconEl) 206 | 207 | 208 | urlEl = self.CreateElementWithData("url","https://tvlistings.gracenote.com//overview.html?programSeriesId=" + event["seriesId"] + "&tmsId=" + event["program"]["id"]) 209 | programEl.appendChild(urlEl) 210 | #Build Season Data 211 | try: 212 | if event["program"]["season"] is not None: 213 | season = str(event["program"]["season"]) 214 | if event["program"]["episode"] is not None: 215 | episode = str(event["program"]["episode"]) 216 | except KeyError: 217 | print("No Season for:" + event["program"]["title"]) 218 | 219 | for category in event["filter"]: 220 | categoryEl = self.CreateElementWithData("category",category.replace('filter-','')) 221 | categoryEl.setAttribute("lang",self.lang) 222 | programEl.appendChild(categoryEl) 223 | 224 | if(int(episode) != 0): 225 | categoryEl = self.CreateElementWithData("category","Series") 226 | programEl.appendChild(categoryEl) 227 | #episodeNum = "S" + str(event["seriesId"]).zfill(2) + "E" + str(episode.zfill(2)) 228 | episodeNum = "S" + str(season).zfill(2) + "E" + str(episode.zfill(2)) 229 | episodeNumEl = self.CreateElementWithData("episode-num",episodeNum) 230 | episodeNumEl.setAttribute("system","common") 231 | programEl.appendChild(episodeNumEl) 232 | seasonStr = "" 233 | if(int(season) != 0): 234 | seasonStr = str(int(season)-1) 235 | episodeNum = seasonStr + "." +str(int(episode)-1) 236 | episodeNumEl = self.CreateElementWithData("episode-num",episodeNum) 237 | episodeNumEl.setAttribute("system", "xmltv_ns") 238 | programEl.appendChild(episodeNumEl) 239 | 240 | if event["program"]["id"[-4:]] == "0000": 241 | episodeNumEl = self.CreateElementWithData("episode-num",event["seriesId"] + '.' + event["program"]["id"][-4:]) 242 | episodeNumEl.setAttribute("system","dd_progid") 243 | else: 244 | episodeNumEl = self.CreateElementWithData("episode-num",event["seriesId"].replace('SH','EP') + '.' + event["program"]["id"][-4:]) 245 | episodeNumEl.setAttribute("system","dd_progid") 246 | programEl.appendChild(episodeNumEl) 247 | 248 | #Handle Flags 249 | for flag in event["flag"]: 250 | if flag == "New": 251 | programEl.appendChild(self.guideXML.createElement("New")) 252 | if flag == "Finale": 253 | programEl.appendChild(self.guideXML.createElement("Finale")) 254 | if flag == "Premiere": 255 | programEl.appendChild(self.guideXML.createElement("Premiere")) 256 | if "New" not in event["flag"]: 257 | programEl.appendChild(self.guideXML.createElement("previously-shown")) 258 | for tag in event["tags"]: 259 | if tag == "CC": 260 | subtitlesEl = self.guideXML.createElement("subtitle") 261 | subtitlesEl.setAttribute("type","teletext") 262 | programEl.appendChild(subtitlesEl) 263 | if event["rating"] is not None: 264 | ratingEl = self.guideXML.createElement("rating") 265 | valueEl = self.CreateElementWithData("value",event["rating"]) 266 | ratingEl.appendChild(valueEl) 267 | return programEl 268 | def BuildXMLDate(self,inTime): 269 | output = inTime.replace('-','').replace('T','').replace(':','') 270 | output = output.replace('Z',' +0000') 271 | return output 272 | def BuildChannelXML(self,channel): 273 | channelEl = self.guideXML.createElement('channel') 274 | channelEl.setAttribute('id',channel["channelId"]) 275 | dispName1 = self.CreateElementWithData("display-name",channel["channelNo"] + " " + channel["callSign"]) 276 | dispName2 = self.CreateElementWithData("display-name",channel["channelNo"]) 277 | dispName3 = self.CreateElementWithData("display-name",channel["callSign"]) 278 | dispName4 = self.CreateElementWithData("display-name",channel["affiliateName"].title()) 279 | iconEl = self.guideXML.createElement("icon") 280 | iconEl.setAttribute("src","http://"+(channel["thumbnail"].partition('?')[0] or "").lstrip('/')) 281 | channelEl.appendChild(dispName1) 282 | channelEl.appendChild(dispName2) 283 | channelEl.appendChild(dispName3) 284 | channelEl.appendChild(dispName4) 285 | channelEl.appendChild(iconEl) 286 | return channelEl 287 | 288 | def CreateElementWithData(self,name,data): 289 | el = self.guideXML.createElement(name) 290 | elText = self.guideXML.createTextNode(data) 291 | el.appendChild(elText) 292 | return el 293 | def GetGuideTimes(self): 294 | currentTimestamp = time.time() 295 | currentTimestamp -= 60 * 60 * 24 296 | halfHourOffset = currentTimestamp % (60 * 30) 297 | currentTimestamp = currentTimestamp - halfHourOffset 298 | days = 14 299 | try: 300 | days = int(self.get_config_value("prefs","guideDays", fallback="14")) 301 | except: 302 | print("guideDays not in config. using default: 14") 303 | print("Loading guide data for ",days," days") 304 | endTimeStamp = currentTimestamp + (60 * 60 * 24 * days) 305 | return (currentTimestamp,endTimeStamp) 306 | def BuildRootEl(self): 307 | self.rootEl = self.guideXML.createElement('tv') 308 | self.rootEl.setAttribute("source-info-url","http://tvlistings.gracenote.com/") 309 | self.rootEl.setAttribute("source-info-name","zap2it") 310 | self.rootEl.setAttribute("generator-info-name","zap2it-GuideScraping)") 311 | self.rootEl.setAttribute("generator-info-url","daniel@widrick.net") 312 | def BuildGuide(self): 313 | self.Authenticate() 314 | self.guideXML = xml.dom.minidom.Document() 315 | impl = xml.dom.minidom.getDOMImplementation() 316 | doctype = impl.createDocumentType("tv","","xmltv.dtd") 317 | self.guideXML.appendChild(doctype) 318 | self.BuildRootEl() 319 | 320 | addChannels = True; 321 | times = self.GetGuideTimes() 322 | loopTime = times[0] 323 | zipCodes = loadZipCodes() 324 | while(loopTime < times[1]): 325 | for zipCode in zipCodes: 326 | zipCode = str(zipCode) 327 | zipCode = zipCode.strip() 328 | zip_json = self.GetData(loopTime,zipCode) 329 | if addChannels: 330 | self.AddChannelsToGuide(zip_json) 331 | self.AddEventsToGuide(zip_json) 332 | addChannels = False 333 | loopTime += (60 * 60 * 3) 334 | self.guideXML.appendChild(self.rootEl) 335 | self.WriteGuide() 336 | self.CopyHistorical() 337 | self.CleanHistorical() 338 | def WriteGuide(self): 339 | with open(self.outputFile,"wb") as file: 340 | file.write(self.guideXML.toprettyxml().encode("utf8")) 341 | def CopyHistorical(self): 342 | dateTimeObj = datetime.datetime.now() 343 | timestampStr = "." + dateTimeObj.strftime("%Y%m%d%H%M%S") + '.xmltv' 344 | histGuideFile = timestampStr.join(optGuideFile.rsplit('.xmltv',1)) 345 | with open(histGuideFile,"wb") as file: 346 | file.write(self.guideXML.toprettyxml().encode("utf8")) 347 | def CleanHistorical(self): 348 | outputFilePath = os.path.abspath(self.outputFile) 349 | outputDir = os.path.dirname(outputFilePath) 350 | for item in os.listdir(outputDir): 351 | fileName = os.path.join(outputDir,item) 352 | if os.path.isfile(fileName) & item.endswith('.xmltv'): 353 | histGuideDays = self.get_config_value("prefs","historicalGuideDays", fallback="30") 354 | if (time.time() - os.stat(fileName).st_mtime) >= int(histGuideDays) * 86400: 355 | os.remove(fileName) 356 | 357 | def showAvailableChannels(self): 358 | allJSON = [] 359 | self.Authenticate() 360 | for zipCode in loadZipCodes(): 361 | zipCode = str(zipCode) 362 | zipCode = zipCode.strip() 363 | print("Loading available channels for: ",zipCode) 364 | my_json = guide.GetData(time.time(), zipCode) 365 | allJSON.append(my_json) 366 | channelList = {} 367 | for zip in allJSON: 368 | for channel in zip["channels"]: 369 | chanid = channel.get("channelId") 370 | chanid = int(chanid) 371 | channelList[chanid] = channel.get("callSign") + "::" + channel.get("channelNo") 372 | print(f'{"CHAN ID":<15}|{"name":<40}|',end='') 373 | for channel in channelList: 374 | print(f'{channel:<15}|',end='') 375 | print(f'{channelList[channel]:<40}') 376 | 377 | def loadZipCodes(): 378 | zipCodes = guide.get_config_value("prefs","zipCode", fallback="") 379 | if zipCodes == "": 380 | print("No Zip Codes configured in config.ini") 381 | print("Please set the zipCode in the config.ini file under [prefs] section") 382 | exit(1) 383 | try: 384 | zipCodes = json.loads(zipCodes) 385 | if not isinstance(zipCodes,list): 386 | zipCodes = [zipCodes] 387 | except json.JSONDecodeError: 388 | zipCodes = [zipCodes] #Support the old format 389 | print("Loaded Zip Codes: ",zipCodes) 390 | return zipCodes 391 | 392 | 393 | #Run the Scraper 394 | optConfigFile = './zap2itconfig.ini' 395 | optGuideFile = 'xmlguide.xmltv' 396 | optLanguage = 'en' 397 | 398 | 399 | parser = argparse.ArgumentParser("Parse Zap2it Guide into XMLTV") 400 | parser.add_argument("-c","--configfile","-i","--ifile", help='Path to config file') 401 | parser.add_argument("-o","--outputfile","--ofile", help='Path to output file') 402 | parser.add_argument("-l","--language", help='Language') 403 | parser.add_argument("-f","--findid", action="store_true", help='Find Headendid / lineupid') 404 | parser.add_argument("-C","--channels", action="store_true", help='List available channels') 405 | parser.add_argument("-w","--web", action="store_true", help="Start a webserver at http://localhost:9000 to serve /xmlguide.xmltv") 406 | 407 | args = parser.parse_args() 408 | print(args) 409 | if args.configfile is not None: 410 | optConfigFile = args.configfile 411 | if args.outputfile is not None: 412 | optGuideFile = args.outputfile 413 | if args.language is not None: 414 | optLanguage = args.language 415 | 416 | guide = Zap2ItGuideScrape(optConfigFile,optGuideFile) 417 | if optLanguage != "en": 418 | guide.lang = optLanguage 419 | 420 | if args.findid is not None and args.findid: 421 | for zipCode in loadZipCodes(): 422 | zipCode = str(zipCode) 423 | #strip whitespace 424 | zipCode = zipCode.strip() 425 | print("Finding IDs for: ",zipCode) 426 | guide.FindID(zipCode) 427 | sys.exit() 428 | if args.channels is not None and args.channels: 429 | guide.showAvailableChannels() 430 | sys.exit() 431 | if args.web is not None and args.web: 432 | import http.server 433 | import socketserver 434 | import threading 435 | PORT = 9000 436 | class httpHandler(http.server.SimpleHTTPRequestHandler): 437 | def do_GET(self): 438 | if self.path == '/xmlguide.xmltv': 439 | self.send_response(200) 440 | self.send_header("Content-type","text/xml") 441 | self.end_headers() 442 | with open(optGuideFile,"rb") as file: 443 | self.wfile.write(file.read()) 444 | else: 445 | self.send_response(404) 446 | self.end_headers() 447 | self.wfile.write(b"404 Not Found") 448 | 449 | Handler = httpHandler 450 | with socketserver.TCPServer(("",PORT),Handler) as httpd: 451 | print("Serving at port",PORT) 452 | def run_guide_build(): 453 | while True: 454 | guide.BuildGuide() 455 | print("Guide Updated") 456 | time.sleep(86400) # Sleep for 24 hours 457 | 458 | guide_thread = threading.Thread(target=run_guide_build) 459 | guide_thread.daemon = True 460 | guide_thread.start() 461 | httpd.serve_forever() 462 | 463 | 464 | guide.BuildGuide() 465 | 466 | 467 | -------------------------------------------------------------------------------- /zap2itconfig.ini.dist: -------------------------------------------------------------------------------- 1 | [creds] 2 | Username: example@emailexmaple.com 3 | Password: examplePass 4 | [prefs] 5 | country: USA 6 | zipCode: [55555] 7 | historicalGuideDays: 14 8 | guideDays: 2 9 | favoriteChannels: 10 | lang: en 11 | [lineup] 12 | headendId: lineupId 13 | lineupId: DFLT 14 | device: - 15 | --------------------------------------------------------------------------------