├── 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://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