TUBI -> v1.0.1

- Remove the need for authorization
- Update API params
- Use DASH instead of HLS
- Add chapters
This commit is contained in:
stabbedbybrick 2025-02-07 18:00:43 +01:00
parent b65c6b7437
commit b4d7f7a03a
2 changed files with 98 additions and 71 deletions

View File

@ -1,21 +1,20 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import os
import re import re
import sys
import uuid
from collections.abc import Generator from collections.abc import Generator
from http.cookiejar import CookieJar from typing import Any
from typing import Any, Optional
from urllib.parse import urljoin
import click import click
import m3u8 from devine.core.downloaders import aria2c, requests
from devine.core.credential import Credential from devine.core.manifests import DASH
from devine.core.downloaders import requests
from devine.core.manifests import HLS
from devine.core.search_result import SearchResult from devine.core.search_result import SearchResult
from devine.core.service import Service from devine.core.service import Service
from devine.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T from devine.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from devine.core.tracks import Chapter, Subtitle, Track, Tracks from devine.core.tracks import Chapter, Chapters, Subtitle, Track, Tracks
from langcodes import Language from langcodes import Language
@ -24,9 +23,9 @@ class TUBI(Service):
Service code for TubiTV streaming service (https://tubitv.com/) Service code for TubiTV streaming service (https://tubitv.com/)
\b \b
Version: 1.0.0 Version: 1.0.1
Author: stabbedbybrick Author: stabbedbybrick
Authorization: Cookies Authorization: None
Robustness: Robustness:
Widevine: Widevine:
L3: 720p, AAC2.0 L3: 720p, AAC2.0
@ -37,10 +36,15 @@ class TUBI(Service):
/series/300001423/gotham /series/300001423/gotham
/tv-shows/200024793/s01-e01-pilot /tv-shows/200024793/s01-e01-pilot
/movies/589279/the-outsiders /movies/589279/the-outsiders
\b
Notes:
- Due to the structure of the DASH manifest and requests downloader failing to output progress,
aria2c is used as the downloader no matter what downloader is specified in the config.
- Search is currently disabled.
""" """
TITLE_RE = r"^(?:https?://(?:www\.)?tubitv\.com?)?/(?P<type>movies|series|tv-shows)/(?P<id>[a-z0-9-]+)" TITLE_RE = r"^(?:https?://(?:www\.)?tubitv\.com?)?/(?P<type>movies|series|tv-shows)/(?P<id>[a-z0-9-]+)"
GEOFENCE = ("us", "ca",)
@staticmethod @staticmethod
@click.command(name="TUBI", short_help="https://tubitv.com/", help=__doc__) @click.command(name="TUBI", short_help="https://tubitv.com/", help=__doc__)
@ -53,48 +57,40 @@ class TUBI(Service):
self.title = title self.title = title
super().__init__(ctx) super().__init__(ctx)
self.license = None # Disable search for now
# def search(self) -> Generator[SearchResult, None, None]:
# params = {
# "search": self.title,
# "include_linear": "true",
# "include_channels": "false",
# "is_kids_mode": "false",
# }
def authenticate( # r = self.session.get(self.config["endpoints"]["search"], params=params)
self, # r.raise_for_status()
cookies: Optional[CookieJar] = None, # results = r.json()
credential: Optional[Credential] = None, # from devine.core.console import console
) -> None: # console.print(results)
super().authenticate(cookies, credential) # exit()
if not cookies:
raise EnvironmentError("Service requires Cookies for Authentication.")
self.session.cookies.update(cookies) # for result in results:
# label = "series" if result["type"] == "s" else "movies" if result["type"] == "v" else result["type"]
def search(self) -> Generator[SearchResult, None, None]: # title = (
params = { # result.get("title", "")
"isKidsMode": "false", # .lower()
"useLinearHeader": "true", # .replace(" ", "-")
"isMobile": "false", # .replace(":", "")
} # .replace("(", "")
# .replace(")", "")
r = self.session.get(self.config["endpoints"]["search"].format(query=self.title), params=params) # .replace(".", "")
r.raise_for_status() # )
results = r.json() # yield SearchResult(
# id_=f"https://tubitv.com/{label}/{result.get('id')}/{title}",
for result in results: # title=result.get("title"),
label = "series" if result["type"] == "s" else "movies" if result["type"] == "v" else result["type"] # description=result.get("description"),
title = ( # label=label,
result.get("title", "") # url=f"https://tubitv.com/{label}/{result.get('id')}/{title}",
.lower() # )
.replace(" ", "-")
.replace(":", "")
.replace("(", "")
.replace(")", "")
.replace(".", "")
)
yield SearchResult(
id_=f"https://tubitv.com/{label}/{result.get('id')}/{title}",
title=result.get("title"),
description=result.get("description"),
label=label,
url=f"https://tubitv.com/{label}/{result.get('id')}/{title}",
)
def get_titles(self) -> Titles_T: def get_titles(self) -> Titles_T:
try: try:
@ -102,11 +98,22 @@ class TUBI(Service):
except Exception: except Exception:
raise ValueError("Could not parse ID from title - is the URL correct?") raise ValueError("Could not parse ID from title - is the URL correct?")
params = {
"platform": "android",
"content_id": content_id,
"device_id": str(uuid.uuid4()),
"video_resources[]": [
"dash",
"dash_widevine",
],
}
if kind == "tv-shows": if kind == "tv-shows":
content = self.session.get(self.config["endpoints"]["content"].format(content_id=content_id)) content = self.session.get(self.config["endpoints"]["content"], params=params)
content.raise_for_status() content.raise_for_status()
series_id = "0" + content.json().get("series_id") series_id = "0" + content.json().get("series_id")
data = self.session.get(self.config["endpoints"]["content"].format(content_id=series_id)).json() params.update({"content_id": int(series_id)})
data = self.session.get(self.config["endpoints"]["content"], params=params).json()
return Series( return Series(
[ [
@ -114,10 +121,10 @@ class TUBI(Service):
id_=episode["id"], id_=episode["id"],
service=self.__class__, service=self.__class__,
title=data["title"], title=data["title"],
season=int(season["id"]), season=int(season.get("id", 0)),
number=int(episode["episode_number"]), number=int(episode.get("episode_number", 0)),
name=episode["title"].split("-")[1], name=episode["title"].split("-")[1],
year=data["year"], year=data.get("year"),
language=Language.find(episode.get("lang", "en")).to_alpha3(), language=Language.find(episode.get("lang", "en")).to_alpha3(),
data=episode, data=episode,
) )
@ -128,7 +135,7 @@ class TUBI(Service):
) )
if kind == "series": if kind == "series":
r = self.session.get(self.config["endpoints"]["content"].format(content_id=content_id)) r = self.session.get(self.config["endpoints"]["content"], params=params)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
@ -138,10 +145,10 @@ class TUBI(Service):
id_=episode["id"], id_=episode["id"],
service=self.__class__, service=self.__class__,
title=data["title"], title=data["title"],
season=int(season["id"]), season=int(season.get("id", 0)),
number=int(episode["episode_number"]), number=int(episode.get("episode_number", 0)),
name=episode["title"].split("-")[1], name=episode["title"].split("-")[1],
year=data["year"], year=data.get("year"),
language=Language.find(episode.get("lang") or "en").to_alpha3(), language=Language.find(episode.get("lang") or "en").to_alpha3(),
data=episode, data=episode,
) )
@ -151,7 +158,7 @@ class TUBI(Service):
) )
if kind == "movies": if kind == "movies":
r = self.session.get(self.config["endpoints"]["content"].format(content_id=content_id)) r = self.session.get(self.config["endpoints"]["content"], params=params)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
return Movies( return Movies(
@ -159,7 +166,7 @@ class TUBI(Service):
Movie( Movie(
id_=data["id"], id_=data["id"],
service=self.__class__, service=self.__class__,
year=data["year"], year=data.get("year"),
name=data["title"], name=data["title"],
language=Language.find(data.get("lang", "en")).to_alpha3(), language=Language.find(data.get("lang", "en")).to_alpha3(),
data=data, data=data,
@ -169,16 +176,27 @@ class TUBI(Service):
def get_tracks(self, title: Title_T) -> Tracks: def get_tracks(self, title: Title_T) -> Tracks:
if not title.data.get("video_resources"): if not title.data.get("video_resources"):
raise ValueError("No video resources found. Title is either missing or geolocation is incorrect.") self.log.error(" - Failed to obtain video resources. Check geography settings.")
self.log.info(f"Title is available in: {title.data.get('country')}")
sys.exit(1)
self.manifest = title.data["video_resources"][0]["manifest"]["url"] self.manifest = title.data["video_resources"][0]["manifest"]["url"]
self.license = title.data["video_resources"][0].get("license_server", {}).get("url") self.license = title.data["video_resources"][0].get("license_server", {}).get("url")
tracks = HLS.from_url(url=self.manifest, session=self.session).to_tracks(language=title.language) tracks = DASH.from_url(url=self.manifest, session=self.session).to_tracks(language=title.language)
for track in tracks: for track in tracks:
master = m3u8.loads(self.session.get(track.url).text, uri=track.url) rep_base = track.data["dash"]["representation"].find("BaseURL")
track.url = urljoin(master.base_uri, master.segments[0].uri) if rep_base is not None:
base_url = os.path.dirname(track.url)
track_base = rep_base.text
track.url = f"{base_url}/{track_base}"
track.descriptor = Track.Descriptor.URL track.descriptor = Track.Descriptor.URL
track.downloader = aria2c
for track in tracks.audio:
role = track.data["dash"]["adaptation_set"].find("Role")
if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
track.descriptive = True
if title.data.get("subtitles"): if title.data.get("subtitles"):
tracks.add( tracks.add(
@ -195,8 +213,17 @@ class TUBI(Service):
) )
return tracks return tracks
def get_chapters(self, title: Title_T) -> list[Chapter]: def get_chapters(self, title: Title_T) -> Chapters:
return [] cue_points = title.data.get("credit_cuepoints")
if not cue_points:
return Chapters()
chapters = []
for title, cuepoint in cue_points.items():
if cuepoint > 0:
chapters.append(Chapter(timestamp=float(cuepoint), name=title))
return Chapters(chapters)
def get_widevine_service_certificate(self, **_: Any) -> str: def get_widevine_service_certificate(self, **_: Any) -> str:
return None return None

View File

@ -1,5 +1,5 @@
endpoints: endpoints:
content: https://tubitv.com/oz/videos/{content_id}/content?video_resources=hlsv6_widevine_nonclearlead&video_resources=hlsv6 content: https://uapi.adrise.tv/cms/content
search: https://tubitv.com/oz/search/{query} search: https://search.production-public.tubi.io/api/v1/search