diff --git a/services/WTCH/__init__.py b/services/WTCH/__init__.py new file mode 100644 index 0000000..7e7b3ac --- /dev/null +++ b/services/WTCH/__init__.py @@ -0,0 +1,213 @@ +import re +import os +import json +import click +from typing import Optional, Union +from http.cookiejar import CookieJar +from bs4 import BeautifulSoup + +from devine.core.config import config +from devine.core.service import Service +from devine.core.titles import Episode, Series +from devine.core.tracks import Tracks +from devine.core.credential import Credential +from devine.core.manifests import HLS +from devine.core.tracks.attachment import Attachment + + +class WTCH(Service): + """ + Service code for watchertv.com + Author: @sp4rk.y + + Authorization: Cookies or Credentials + Security: None + """ + + TITLE_RE = r"^(?:https?://(?:www\.)?watchertv\.com/)([^/]+)(?:/.*)?$" + SERIES_RE = r"https?://(?:www\.)?watchertv\.com/([^/]+)(?:/season:(\d+))?/?$" + EPISODE_RE = r"https?://(?:www\.)?watchertv\.com/([^/]+)/season:(\d+)/videos/([^/]+)/?$" + + @staticmethod + @click.command(name="WTCH", short_help="https://watchertv.com", help=__doc__) + @click.argument("title", type=str) + @click.pass_context + def cli(ctx, **kwargs): + return WTCH(ctx, **kwargs) + + def __init__(self, ctx, title: str): + self.title = title + super().__init__(ctx) + + def authenticate( + self, + cookies: Optional[CookieJar] = None, + credential: Optional[Credential] = None, + ) -> None: + self.credentials = credential + + if cookies: + self.session.cookies.update(cookies) + elif self.credentials: + login_data = { + "email": self.credentials.username, + "password": self.credentials.password, + "authenticity_token": self._get_authenticity_token(), + "utf8": "true", + } + + # Use the URL from the config + response = self.session.post( + self.config["endpoints"]["login_url"], + data=login_data, + allow_redirects=False, + ) + + if '
Union[Series]: + match = re.match(self.SERIES_RE, self.title) + if match: + title_id = match.group(1) + else: + title_id = self.title + + url = self.config["endpoints"]["episode_metadata_url"].format(title_id=title_id) + response = self.session.get(url) + soup = BeautifulSoup(response.text, "html.parser") + + episodes = [] + season_urls = [] + + season_select = soup.find("select", class_="js-switch-season") + if season_select: + for option in season_select.find_all("option"): + season_urls.append(option["value"]) + + for season_url in season_urls: + season_response = self.session.get(season_url) + season_soup = BeautifulSoup(season_response.text, "html.parser") + + season_number = int(re.search(r"/season:(\d+)", season_url).group(1)) + + for item in season_soup.find_all("div", class_="browse-item-card"): + episode_link = item.find("a", class_="browse-item-link") + if episode_link: + episode_url = episode_link["href"] + episode_data = json.loads(episode_link["data-track-event-properties"]) + + episode_id = episode_data["id"] + episode_title = episode_data["label"] + + episode_number_elem = item.find("span", class_="media-identifier media-episode") + if episode_number_elem: + episode_number_match = re.search(r"Episode (\d+)", episode_number_elem.text) + if episode_number_match: + episode_number = int(episode_number_match.group(1)) + else: + continue + else: + continue + + show_title = self.title.split("/")[-1].replace("-", " ").title() + + episode = Episode( + id_=str(episode_id), + service=self.__class__, + title=show_title, + season=season_number, + number=episode_number, + name=episode_title, + year=None, + data={"url": episode_url}, + ) + episodes.append(episode) + + return Series(episodes) + + def get_tracks(self, title: Union[Episode]) -> Tracks: + tracks = Tracks() + + episode_url = title.data["url"] + episode_page = self.session.get(episode_url).text + + embed_url_match = re.search(self.config["endpoints"]["embed_url_regex"], episode_page) + if not embed_url_match: + raise ValueError("Could not find embed_url in the episode page") + embed_url = embed_url_match.group(1).replace("&", "&") + + headers = {k: v.format(episode_url=episode_url) for k, v in self.config["headers"].items()} + + # Fetch the embed page content + embed_page = self.session.get(embed_url, headers=headers).text + + # Extract the config URL using regex + config_url_match = re.search(self.config["endpoints"]["config_url_regex"], embed_page) + if config_url_match: + config_url = config_url_match.group(1).replace("\\u0026", "&") + else: + raise ValueError("Config URL not found on the embed page.") + + config_data = self.session.get(config_url, headers=headers).json() + + # Retrieve the CDN information from the config data + cdns = config_data["request"]["files"]["hls"]["cdns"] + default_cdn = config_data["request"]["files"]["hls"]["default_cdn"] + + # Select the default CDN or fall back to the first available one + cdn = cdns.get(default_cdn) or next(iter(cdns.values())) + + # Generate the MPD URL by replacing 'playlist.json' with 'playlist.mpd' + mpd_url = cdn["avc_url"].replace("playlist.json", "playlist.mpd") + + tracks = HLS.from_url(url=mpd_url).to_tracks(language="en") + + # Extract thumbnail URL from config_data + thumbnail_base_url = config_data["video"]["thumbs"]["base"] + thumbnail_url = f"{thumbnail_base_url}" + thumbnail_response = self.session.get(thumbnail_url) + if thumbnail_response.status_code == 200: + thumbnail_filename = f"{title.id}_thumbnail.jpg" + thumbnail_path = config.directories.temp / thumbnail_filename + + # Ensure the directory exists + os.makedirs(config.directories.temp, exist_ok=True) + + # Save the thumbnail file + with open(thumbnail_path, "wb") as f: + f.write(thumbnail_response.content) + + # Create an Attachment object + thumbnail_attachment = Attachment( + path=thumbnail_path, + name=thumbnail_filename, + mime_type="image/jpeg", + description="Thumbnail", + ) + + # Add the attachment to the tracks + tracks.attachments.append(thumbnail_attachment) + + return tracks + + def get_chapters(self, title): + return [] + + def get_widevine_license(self, challenge: bytes, title: Union[Episode], track): + # No DRM + pass diff --git a/services/WTCH/config.yaml b/services/WTCH/config.yaml new file mode 100644 index 0000000..3c07743 --- /dev/null +++ b/services/WTCH/config.yaml @@ -0,0 +1,15 @@ +endpoints: + login_url: "https://www.watchertv.com/login" + episode_metadata_url: "https://www.watchertv.com/{title_id}" + embed_url_regex: 'embed_url:\s*"([^"]+)"' + config_url_regex: 'config_url":"([^"]+)"' + +headers: + referer: "{episode_url}" + user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8" + accept_language: "en-US,en;q=0.5" + upgrade_insecure_requests: "1" + sec_fetch_dest: "iframe" + sec_fetch_mode: "navigate" + sec_fetch_site: "cross-site"