├── requirements.txt ├── Dockerfile ├── tubi_tmsid.csv ├── .github └── workflows │ └── push-docker-hub.yml ├── README.md ├── pywsgi.py └── tubi.py /requirements.txt: -------------------------------------------------------------------------------- 1 | gevent 2 | flask 3 | requests 4 | schedule 5 | pytz 6 | bs4 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-alpine3.20 2 | 3 | EXPOSE 7777/tcp 4 | 5 | ENV PYTHONUNBUFFERED=1 6 | 7 | WORKDIR /app 8 | 9 | COPY requirements.txt ./ 10 | RUN pip install --no-cache-dir --disable-pip-version-check --no-compile -r requirements.txt 11 | 12 | COPY pywsgi.py ./ 13 | COPY tubi.py ./ 14 | COPY tubi_tmsid.csv/ ./ 15 | 16 | CMD ["python3","pywsgi.py"] -------------------------------------------------------------------------------- /tubi_tmsid.csv: -------------------------------------------------------------------------------- 1 | id,name,tmsid,time_shift 2 | 400000012,ACCDN,124806 3 | 653208,Always Funny,134109, 4 | 715950,At the Movies,170368 5 | 555124,Bloomberg TV+,71799 6 | 571664,Bloomberg Originals,123870, 7 | 555130,CBC News,124721, 8 | 555126,Cheddar,107241 9 | 715949,Crime Scenes,169318 10 | 400000088,Dog the Bounty Hunter,150981 11 | 400000056,Ebony TV by Lionsgate,146144, 12 | 715948,Family Unscripted,169317 13 | 628893,FOX Weather,121307 14 | 400000070,Generation Drama,169320 15 | 715951,Ghosts are Real,169310, 16 | 685558,Hi-YAH!,120790, 17 | 715952,How To,169315 18 | 400000073,In the Garage,169605, 19 | 400000033,Kartoon Channel!,169820 20 | 400000067,Living with Evil,170382 21 | 715947,Love & Marriage,138034 22 | 670605,Love Quest,149920 23 | 400000108,MotorTrend FAST TV,126737 24 | 400000048,MrBeast,146284 25 | 715946,Mysterious Worlds,169313 26 | 400000105,Nash Bridges,158128 27 | 400000116,NBA FAST Channel,171941 28 | 629323,NHRA TV,129130 29 | 400000072,On the Telly,169610 30 | 715939,Paws and Claws,169312 31 | 400000004,Revolt Mixtape,129774, 32 | 578086,Scripps News,120010 33 | 715938,Sweet Escapes,169611, 34 | 702891,The Jack Hanna Channel,132922, 35 | 700412,Total Crime,131220 36 | 715942,Unique Lives,169646 37 | 666613,Vice,135718, 38 | 715945,Welcome Home,169645 39 | -------------------------------------------------------------------------------- /.github/workflows/push-docker-hub.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | tags: 8 | - '*' 9 | 10 | jobs: 11 | docker: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - 15 | name: Checkout 16 | uses: actions/checkout@v4 17 | - 18 | name: Set up QEMU 19 | uses: docker/setup-qemu-action@v3 20 | - 21 | name: Set up Docker Buildx 22 | id: buildx 23 | uses: docker/setup-buildx-action@v3 24 | - 25 | name: Login to DockerHub 26 | if: github.event_name != 'pull_request' 27 | uses: docker/login-action@v3 28 | with: 29 | registry: ghcr.io 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GH_PAT }} 32 | - 33 | name: Docker meta 34 | id: meta 35 | uses: docker/metadata-action@v3 36 | with: 37 | images: | 38 | ghcr.io/jgomez177/tubi-for-channels 39 | tags: | 40 | type=raw,value=latest,enable=${{ endsWith(github.ref, 'main') }} 41 | type=ref,event=tag 42 | - 43 | name: Build and push 44 | uses: docker/build-push-action@v3 45 | with: 46 | context: . 47 | platforms: linux/amd64,linux/arm64 48 | push: ${{ github.event_name != 'pull_request' }} 49 | tags: ${{ steps.meta.outputs.tags }} 50 | labels: ${{ steps.meta.outputs.labels }} 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tubi for Channels 2 | 3 | Current version: **3.01** 4 | 5 | # About 6 | This takes Tubi Live TV Channels and generates an M3U playlist and EPG XMLTV file. 7 | 8 | If you like this and other linear containers for Channels that I have created, please consider supporting my work. 9 | 10 | [![](https://pics.paypal.com/00/s/MDY0MzZhODAtNGI0MC00ZmU5LWI3ODYtZTY5YTcxOTNlMjRm/file.PNG)](https://www.paypal.com/donate/?hosted_button_id=BBUTPEU8DUZ6J) 11 | 12 | 13 | # Changes 14 | - Verion 3.02: 15 | - Updates to EPG Data 16 | - Verion 3.01: Rolling back SB LIX 17 | - Version 3.00d: 18 | - Super Bowl LIX EPG. 19 | - Version 3.00c: 20 | - Super Bowl LIX support. 21 | - Version 3.00b: 22 | - Rollback of code specifically for no credentials. 23 | - Version 3.00: 24 | - Total rebuild of container. 25 | - New API calling 26 | - Improved error handling 27 | - Improved threading 28 | - Version 2.06: 29 | - More Internal Updates 30 | - Version 2.05: 31 | - Added Group Listings 32 | - Version 2.04: 33 | - Corrected timing for EPG updates 34 | - Version 2.03: 35 | - Corrected Bad Function Call 36 | - Version 2.02: 37 | - Adding multithreading 38 | - Version 2.01: 39 | - Additional improvements and logging 40 | - Version 2.00: 41 | - Included support for email signin (not Google authentication) 42 | - Additional Updates 43 | - Version 1.03a: 44 | - More error handling 45 | - Version 1.03: 46 | - Added additional error handling 47 | - Version 1.02: 48 | - Updated TMSID handing to clear incorrect Tubi listed TMSIDs 49 | - Version 1.01: 50 | - Added Error handling for when channel does not have URL Stream 51 | - Version 1.00: 52 | - Main Release 53 | - Added EPG Scheduler. 54 | - Updates to Gracenote Mapping 55 | 56 | 57 | # Running 58 | The recommended way of running is to pull the image from [GitHub](https://github.com/jgomez177/tubi-for-channels/pkgs/container/tubi-for-channels). 59 | 60 | docker run -d --restart unless-stopped --network=host -e TUBI_PORT=[your_port_number_here] --name tubi-for-channels ghcr.io/jgomez177/tubi-for-channels 61 | or 62 | 63 | docker run -d --restart unless-stopped -p [your_port_number_here]:7777 --name tubi-for-channels ghcr.io/jgomez177/tubi-for-channels 64 | 65 | You can retrieve the playlist and EPG via the status page. 66 | 67 | http://127.0.0.1:[your_port_number_here] 68 | 69 | ## Environement Variables 70 | | Environment Variable | Description | Default | 71 | |---|---|---| 72 | | TUBI_PORT | Port the API will be served on. You can set this if it conflicts with another service in your environment. | 7777 | 73 | | TUBI_USER | Optional variable to sign into Tubi Account. | None | 74 | | TUBI_PASS | Optional variable to sign into Tubi Account. | None | 75 | 76 | ## Additional URL Parameters 77 | | Parameter | Description | 78 | |---|---| 79 | | gracenote | "include" will utilize gracenote EPG information and filter to those streams utilizing Gracenote. "exclude" will filter those streams that do not have a matching gracenote EPG data. | 80 | 81 | ## Optional Custom Gracenote ID Matching 82 | 83 | Adding a docker volume to /app/tubi_data will allow you to add a custom comma delimited csv file to add or change any of the default gracenote matching for any tubi channel 84 | 85 | docker run -d --restart unless-stopped --network=host -e TUBI_PORT=[your_port_number_here] -v [your_file_location_here]:/app/tubi_data --name tubi-for-channels ghcr.io/jgomez177/tubi-for-channels 86 | 87 | Create a file called `tubi_custom_tmsid.csv` with the following headers (case-sensitive): 88 | | id | name | tmsid | time_shift | 89 | |---|---|---|---| 90 | | (required) id of the Tubi channel (more on obtaining this ID below) | (optional) Easy to read name | (required) New/Updated Gracenote TMSID number for the channel | (optional) Shifting EPG data for the channel in hours. Ex: To shift the EPG 5 hours earlier, value would be -5 | 91 | 92 | Example 93 | 94 | id,name,tmsid,time_shift 95 | 400000011,TV One Crime & Justice,145680, 96 | 97 | 98 | -------------------------------------------------------------------------------- /pywsgi.py: -------------------------------------------------------------------------------- 1 | from gevent.pywsgi import WSGIServer 2 | from flask import Flask, redirect, request, Response, send_file 3 | from threading import Thread 4 | import os, importlib, schedule, time 5 | from gevent import monkey 6 | monkey.patch_all() 7 | 8 | version = "3.02" 9 | updated_date = "Mar 19, 2025" 10 | 11 | # Retrieve the port number from env variables 12 | # Fallback to default if invalid or unspecified 13 | try: 14 | port = int(os.environ.get("TUBI_PORT", 7777)) 15 | except: 16 | port = 7777 17 | 18 | 19 | # instance of flask application 20 | app = Flask(__name__) 21 | provider = "tubi" 22 | providers = { 23 | provider: importlib.import_module(provider).Client(), 24 | } 25 | 26 | url_main = f'\ 27 | \ 28 | \ 29 | \ 30 | \ 31 | Playlist\ 32 | \ 33 | \ 34 | \ 35 |
\ 36 |
\ 37 |

\ 38 | Playlist\ 39 | v{version}\ 40 | Last Updated: {updated_date}\ 41 |

\ 42 |
' 43 | 44 | @app.route("/") 45 | def index(): 46 | host = request.host 47 | body = '
' 48 | for pvdr in providers: 49 | body_text = providers[pvdr].body_text(pvdr, host) 50 | body += body_text 51 | body += "
" 52 | return f"{url_main}{body}" 53 | 54 | @app.route("//token") 55 | def token(provider): 56 | # host = request.host 57 | token, error = providers[provider].token() 58 | if error: 59 | return error 60 | else: 61 | return token 62 | 63 | @app.get("//playlist.m3u") 64 | def playlist(provider): 65 | args = request.args 66 | host = request.host 67 | 68 | m3u, error = providers[provider].generate_playlist(provider, args, host) 69 | if error: return error, 500 70 | response = Response(m3u, content_type='audio/x-mpegurl') 71 | return (response) 72 | 73 | @app.get("//channels.json") 74 | def channels_json(provider): 75 | stations, err = providers[provider].channels() 76 | if err: return (err) 77 | return (stations) 78 | 79 | @app.route("//watch/") 80 | def watch(provider, id): 81 | video_url, err = providers[provider].generate_video_url(id) 82 | if err: return "Error", 500, {'X-Tuner-Error': err} 83 | if not video_url:return "Error", 500, {'X-Tuner-Error': 'No Video Stream Detected'} 84 | # print(f'[INFO] {video_url}') 85 | return (redirect(video_url)) 86 | 87 | 88 | @app.route("//super-bowl/epg") 89 | def fox_super_bowl_lix(provider): 90 | stations, err = providers[provider].fox_super_bowl_lix() 91 | if err: return (err) 92 | return (stations) 93 | 94 | @app.get("//super-bowl/playlist.m3u") 95 | def fox_super_bowl_lix_playlist(provider): 96 | args = request.args 97 | host = request.host 98 | 99 | m3u, error = providers[provider].generate_sb_playlist(provider, args, host) 100 | if error: return error, 500 101 | response = Response(m3u, content_type='audio/x-mpegurl') 102 | return (response) 103 | 104 | @app.route("//watch/super-bowl/") 105 | def fox_super_bowl_lix_watch(provider, id): 106 | video_url, err = providers[provider].generate_super_bowl_video_url(id) 107 | print(f'[INFO] {video_url}') 108 | print(err) 109 | 110 | 111 | if err: return "Error", 500, {'X-Tuner-Error': err} 112 | if not video_url:return "Error", 500, {'X-Tuner-Error': 'No Video Stream Detected'} 113 | print(f'[INFO] {video_url}') 114 | return (redirect(video_url)) 115 | # return (video_url) 116 | 117 | @app.get("//") 118 | def epg_xml(provider, filename): 119 | 120 | ALLOWED_EPG_FILENAMES = ['epg.xml', 'sb-epg.xml'] 121 | ALLOWED_GZ_FILENAMES = ['epg.xml.gz', 'sb-epg.xml.gz'] 122 | 123 | try: 124 | if filename not in ALLOWED_EPG_FILENAMES and filename not in ALLOWED_GZ_FILENAMES: 125 | # Check if the provided filename is allowed 126 | # if filename not in ALLOWED_EPG_FILENAMES: 127 | return "Invalid filename", 400 128 | error = providers[provider].epg() 129 | if error: return "Error in processing EPG Data", 400 130 | 131 | # Specify the file path based on the provider and filename 132 | file_path = f'{filename}' 133 | 134 | # Return the file without explicitly opening it 135 | if filename in ALLOWED_EPG_FILENAMES: 136 | return send_file(file_path, as_attachment=False, download_name=file_path, mimetype='text/plain') 137 | elif filename in ALLOWED_GZ_FILENAMES: 138 | return send_file(file_path, as_attachment=True, download_name=file_path) 139 | except FileNotFoundError: 140 | return "XML file not found", 404 141 | 142 | # Define the function you want to execute with scheduler 143 | def epg_scheduler(): 144 | print(f"[INFO] Running EPG Scheduler") 145 | 146 | 147 | try: 148 | error = providers[provider].epg() 149 | if error: 150 | print(f"[ERROR] EPG: {error}") 151 | except Exception as e: 152 | print(f"[ERROR] Exception in EPG Scheduler : {e}") 153 | print(f"[INFO] EPG Scheduler Complete") 154 | 155 | # try: 156 | # sb_channels, error = providers[provider].fox_super_bowl_lix() 157 | # if error: 158 | # print(f"[ERROR] Super Bowl LIX: {error}") 159 | # except Exception as e: 160 | # print(f"[ERROR] Exception in Super Bowl LIX Scheduler : {e}") 161 | # print(f"[INFO] Super Bowl LIX Scheduler Complete") 162 | 163 | 164 | 165 | # Define a function to run the scheduler in a separate thread 166 | def scheduler_thread(): 167 | 168 | # Define a task for this country 169 | schedule.every(1).hours.do(epg_scheduler) 170 | 171 | # Run the task immediately when the thread starts 172 | while True: 173 | try: 174 | epg_scheduler() 175 | except Exception as e: 176 | print(f"[ERROR] Error in running scheduler, retrying: {e}") 177 | continue # Immediately retry 178 | 179 | # Continue as Scheduled 180 | while True: 181 | try: 182 | schedule.run_pending() 183 | time.sleep(1) 184 | except Exception as e: 185 | print(f"[ERROR] Error in scheduler thread: {e}") 186 | break # Restart the loop and rerun epg_scheduler 187 | 188 | # Function to monitor and restart the thread if needed 189 | def monitor_thread(): 190 | def thread_wrapper(): 191 | print(f"[INFO] Starting Scheduler thread") 192 | scheduler_thread() 193 | 194 | thread = Thread(target=thread_wrapper, daemon=True) 195 | thread.start() 196 | 197 | while True: 198 | if not thread.is_alive(): 199 | print(f"[ERROR] Scheduler thread stopped. Restarting...") 200 | thread = Thread(target=thread_wrapper, daemon=True) 201 | thread.start() 202 | time.sleep(15 * 60) # Check every 15 minutes 203 | print(f"[INFO] Checking scheduler thread") 204 | 205 | 206 | if __name__ == '__main__': 207 | try: 208 | Thread(target=monitor_thread, daemon=True).start() 209 | print(f"[INFO] ⇨ http server started on [::]:{port}") 210 | WSGIServer(('', port), app, log=None).serve_forever() 211 | except OSError as e: 212 | print(str(e)) 213 | -------------------------------------------------------------------------------- /tubi.py: -------------------------------------------------------------------------------- 1 | import json, os, uuid, threading, requests, time, base64, binascii, pytz, gzip, csv, os 2 | import xml.etree.ElementTree as ET 3 | from datetime import datetime 4 | from bs4 import BeautifulSoup 5 | import re 6 | from urllib.parse import unquote 7 | 8 | class Client: 9 | def __init__(self): 10 | self.lock = threading.Lock() 11 | self.load_device() 12 | self.generate_verifier() 13 | self.user = os.environ.get("TUBI_USER") 14 | self.passwd = os.environ.get("TUBI_PASS") 15 | self.token_sessionAt = time.time() 16 | self.token_expires_in = 0 17 | self.tokenResponse = None 18 | self.sessionAt = 0 19 | self.session_expires_in = 0 20 | self.channel_list = [] 21 | 22 | self.headers = { 23 | 'accept': '*/*', 24 | 'accept-language': 'en-US', 25 | 'content-type': 'application/json', 26 | 'origin': 'https://tubitv.com', 27 | 'priority': 'u=1, i', 28 | 'referer': 'https://tubitv.com/', 29 | 'sec-ch-ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', 30 | 'sec-ch-ua-mobile': '?0', 31 | 'sec-ch-ua-platform': '"Windows"', 32 | 'sec-fetch-dest': 'empty', 33 | 'sec-fetch-mode': 'cors', 34 | 'sec-fetch-site': 'cross-site', 35 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 36 | } 37 | 38 | 39 | def is_uuid4(self, string): 40 | try: 41 | uuid_obj = uuid.UUID(string, version=4) 42 | return str(uuid_obj) == string 43 | except ValueError: 44 | return False 45 | 46 | def isTimeExpired(self, sessionAt, age): 47 | # print ((time.time() - sessionAt), age) 48 | return ((time.time() - sessionAt) >= age) 49 | 50 | def load_device(self): 51 | device_file = "tubi-device.json" 52 | try: 53 | with open(device_file, "r") as f: 54 | device_id = json.load(f) 55 | except FileNotFoundError: 56 | device_id = str(uuid.uuid4()) 57 | with open(device_file, "w") as f: 58 | json.dump(device_id, f) 59 | print(f"[INFO] Device ID Generated") 60 | is_valid_uuid4 = self.is_uuid4(device_id) 61 | if not is_valid_uuid4: 62 | print(f"[WARNING] Device ID Not Valid: {device_id}") 63 | print(f"[WARNING] Reload Device ID") 64 | os.remove(device_file) 65 | self.load_device() 66 | else: 67 | # print(f"[INFO] Device ID: {device_id}") 68 | with self.lock: 69 | self.device_id = device_id 70 | 71 | def body_text(self, provider, host): 72 | body_text = f'

{provider.capitalize()} Playlist

' 73 | ul = f'

' 74 | pl = f"http://{host}/{provider}/playlist.m3u" 75 | ul += f"{provider.upper()}: {pl}
" 76 | pl = f"http://{host}/{provider}/playlist.m3u?gracenote=include" 77 | ul += f"{provider.upper()} Gracenote Playlist: {pl}
" 78 | pl = f"http://{host}/{provider}/playlist.m3u?gracenote=exclude" 79 | ul += f"{provider.upper()} EPG Only Playlist: {pl}
" 80 | pl = f"http://{host}/{provider}/epg.xml" 81 | ul += f"{provider.upper()} EPG: {pl}
" 82 | pl = f"http://{host}/{provider}/epg.xml.gz" 83 | ul += f"{provider.upper()} EPG GZ: {pl}

" 84 | # pl = f"http://{host}/{provider}/super-bowl/playlist.m3u" 85 | # ul += f"{provider.upper()} SUPER BOWL LIX: {pl}
" 86 | # pl = f"http://{host}/{provider}/sb-epg.xml" 87 | # ul += f"{provider.upper()} SUPER BOWL LIX EPG: {pl}
" 88 | # pl = f"http://{host}/{provider}/sb-epg.xml.gz" 89 | # ul += f"{provider.upper()} SUPER BOWL LIX EPG GZ: {pl}

" 90 | ul += f"
" 91 | return(f'{body_text}{ul}') 92 | 93 | def call_token_api(self, json_data, local_headers, isAnonymous): 94 | if isAnonymous: 95 | url = 'https://account.production-public.tubi.io/device/anonymous/token' 96 | else: 97 | url = 'https://account.production-public.tubi.io/user/login' 98 | 99 | error = None 100 | # print(json.dumps(json_data, indent = 2)) 101 | local_token_sessionAt = self.token_sessionAt 102 | local_token_expires_in = self.token_expires_in 103 | tokenResponse = self.tokenResponse 104 | 105 | if self.isTimeExpired(local_token_sessionAt, local_token_expires_in) or (tokenResponse is None): 106 | print("[INFO] Update Token via API Call") 107 | print("[INFO] Updating Token Session") 108 | local_token_sessionAt = time.time() 109 | try: 110 | session = requests.Session() 111 | tokenResponse = session.post(url, json=json_data, headers=local_headers) 112 | except requests.ConnectionError as e: 113 | error = f"Connection Error. {str(e)}" 114 | finally: 115 | # print(error) 116 | # print(data) 117 | print('[INFO] Close the Token API session') 118 | session.close() 119 | else: 120 | print("[INFO] Return Token") 121 | 122 | if error: 123 | print(error) 124 | return None, None, None, error 125 | 126 | if tokenResponse.status_code != 200: 127 | print(f"HTTP: {tokenResponse.status_code}: {tokenResponse.text}") 128 | return None, None, None, tokenResponse.text 129 | else: 130 | resp = tokenResponse.json() 131 | 132 | # print(json.dumps(resp, indent = 2)) 133 | with self.lock: 134 | self.tokenResponse = tokenResponse 135 | access_token = resp.get('access_token', None) 136 | local_token_expires_in = resp.get('expires_in', local_token_expires_in) 137 | print(f"[INFO] Token Expires IN: {local_token_expires_in}") 138 | 139 | return (access_token, local_token_sessionAt, local_token_expires_in, error) 140 | 141 | def use_signin_creds(self, local_user, local_passwd): 142 | local_device_id = self.device_id 143 | local_headers = self.headers.copy() 144 | 145 | json_data = { 146 | 'type': 'email', 147 | 'platform': 'web', 148 | 'device_id': local_device_id, 149 | 'credentials': { 150 | 'email': local_user, 151 | 'password': local_passwd 152 | }, 153 | 'errorLog': False, 154 | } 155 | return self.call_token_api(json_data, local_headers, False) 156 | 157 | 158 | def generate_challenge_text(self): 159 | random_bytes = os.urandom(32) # Generate 32 random bytes 160 | challenge_text = base64.urlsafe_b64encode(random_bytes).rstrip(b'=').decode('utf-8') 161 | return challenge_text 162 | 163 | def generate_verifier(self): 164 | random_bytes = os.urandom(16) # Generate 16 random bytes 165 | verifier = binascii.hexlify(random_bytes).decode('utf-8') 166 | with self.lock: 167 | self.verifier = verifier 168 | return None 169 | 170 | def generate_anonymous_token(self, device_id, id): 171 | error = None 172 | verifier = self.verifier 173 | local_headers = self.headers.copy() 174 | 175 | # params = {'X-Tubi-Algorithm': 'TUBI-HMAC-SHA256'} 176 | 177 | json_data = { 178 | 'verifier': verifier, 179 | 'id': id, 180 | 'platform': 'web', 181 | 'device_id': device_id, 182 | } 183 | 184 | return self.call_token_api(json_data, local_headers, True) 185 | 186 | 187 | def use_anonymous_creds(self): 188 | # print('[INFO] Using anonymous credentials') 189 | error = '[ERROR] Anonymous Credentials Not Functioning. Please use username/password credentials' 190 | if error: 191 | print(error) 192 | # os._exit(-999) 193 | 194 | challenge = self.generate_challenge_text() 195 | device_id = self.device_id 196 | headers = self.headers.copy() 197 | json_data = { 198 | 'challenge': challenge, 199 | 'version': '1.0.0', 200 | 'platform': 'web', 201 | 'device_id': device_id, 202 | } 203 | 204 | try: 205 | session = requests.Session() 206 | response = session.post('https://account.production-public.tubi.io/device/anonymous/signing_key', headers=headers, json=json_data) 207 | except requests.ConnectionError as e: 208 | error = f"Connection Error. {str(e)}" 209 | finally: 210 | session.close() 211 | 212 | # print(f"HTTP: {response.status_code}: {response.text}") 213 | 214 | if error: 215 | print(error) 216 | return None, None, None, error 217 | 218 | if response.status_code != 200: 219 | print(f"HTTP: {response.status_code}: {response.text}") 220 | return None, None, None, response.text 221 | 222 | resp = response.json() 223 | id = resp.get('id') 224 | key = resp.get('key') 225 | # print(id) 226 | return (self.generate_anonymous_token(device_id, id)) 227 | 228 | 229 | def token(self): 230 | local_user = self.user 231 | local_passwd = self.passwd 232 | error = None 233 | 234 | if local_user is None or local_passwd is None: 235 | access_token, local_token_sessionAt, local_token_expires_in, error = self.use_anonymous_creds() 236 | if error: 237 | print(f'[ERROR] Error in use_anonymous_creds {error}') 238 | return None, error 239 | with self.lock: 240 | self.access_token = access_token 241 | self.token_expires_in = local_token_expires_in 242 | self.token_sessionAt = local_token_sessionAt 243 | else: 244 | access_token, local_token_sessionAt, local_token_expires_in, error = self.use_signin_creds(local_user, local_passwd) 245 | if error: 246 | print(f'[ERROR] Error in use_signin_creds {error}') 247 | return None, error 248 | with self.lock: 249 | self.access_token = access_token 250 | self.token_expires_in = local_token_expires_in 251 | self.token_sessionAt = local_token_sessionAt 252 | 253 | return access_token, error 254 | 255 | def update_tmsid(self, channel_dict): 256 | tubi_tmsid_url = "https://raw.githubusercontent.com/jgomez177/tubi-for-channels/main/tubi_tmsid.csv" 257 | tubi_custom_tmsid = 'tubi_data/tubi_custom_tmsid.csv' 258 | 259 | tmsid_dict = {} 260 | tmsid_custom_dict = {} 261 | 262 | # Fetch the CSV file from the URL 263 | with requests.Session() as session: 264 | response = session.get(tubi_tmsid_url) 265 | # print(response.text) 266 | 267 | # Check if request was successful 268 | if response.status_code == 200: 269 | # Read in the CSV data 270 | # print("[INFO] Read in file data from github") 271 | reader = csv.DictReader(response.text.splitlines()) 272 | else: 273 | # Use local cache instead 274 | print("[NOTIFICATION] Failed to fetch the CSV file. Status code:", response.status_code) 275 | print("[NOTIFICATION] Using local cached file.") 276 | with open('tubi_tmsid.csv', mode='r') as file: 277 | reader = csv.DictReader(file) 278 | for row in reader: 279 | tmsid_dict[row['id']] = row 280 | 281 | if os.path.exists(tubi_custom_tmsid): 282 | # File exists, open it 283 | with open(tubi_custom_tmsid, mode='r') as file: 284 | reader = csv.DictReader(file) 285 | for row in reader: 286 | tmsid_custom_dict[row['id']] = row 287 | 288 | tmsid_dict.update(tmsid_custom_dict) 289 | 290 | # print(json.dumps(tmsid_dict, indent=2)) 291 | 292 | for listing in tmsid_dict: 293 | cid = listing 294 | tmsid = tmsid_dict[listing].get('tmsid') 295 | time_shift = tmsid_dict[listing].get('time_shift') 296 | 297 | if cid in channel_dict: 298 | name = channel_dict[cid].get('name') 299 | old_tmsid = channel_dict[cid].get('tmsid') 300 | print(f"[INFO] Updating {name} with {tmsid} from {old_tmsid}") 301 | channel_dict[cid].update({'tmsid': tmsid}) # Updates channel_list in place 302 | if time_shift: 303 | print(f"[INFO] Add Time Shift") 304 | channel_dict[cid].update({'time_shift': time_shift}) # Updates channel_list in place 305 | 306 | return 307 | 308 | def replace_quotes(self, match): 309 | return '"' + match.group(1).replace('"', r'\"') + '"' 310 | 311 | def channel_id_list_anon(self): 312 | url = "https://tubitv.com/live" 313 | params = {} 314 | error = None 315 | headers = self.headers 316 | 317 | try: 318 | session = requests.Session() 319 | response = session.get(url, params=params, headers=headers) 320 | except Exception as e: 321 | error = f"read_from_tubi Exception type Error: {type(e).__name__}" 322 | finally: 323 | print('[INFO] Close the Signin API session') 324 | session.close() 325 | 326 | if error: return None, error 327 | if (response.status_code != 200): 328 | return (f"tubitv.com/live HTTP failure {response.status_code}: {response.text}") 329 | 330 | html_content = response.text 331 | 332 | # Parse the HTML 333 | soup = BeautifulSoup(html_content, "html.parser") 334 | 335 | # Find all