from __future__ import annotations import json import re import uuid from collections.abc import Generator from concurrent.futures import ThreadPoolExecutor from http.cookiejar import MozillaCookieJar from typing import Any, Optional from urllib.parse import quote, urljoin, urlparse import click from click import Context from requests import Request from unshackle.core.credential import Credential from unshackle.core.manifests import DASH, HLS from unshackle.core.search_result import SearchResult from unshackle.core.service import Service from unshackle.core.titles import Episode, Movie, Movies, Series from unshackle.core.tracks import Chapter, Chapters, Tracks class PLEX(Service): """ \b Service code for Plex's free streaming service (https://watch.plex.tv/). \b Version: 1.0.4 Author: stabbedbybrick Authorization: None Geofence: API and downloads are locked into whatever region the user is in Robustness: L3: 720p, AAC2.0 \b Tips: - Input should be complete URL: SHOW: https://watch.plex.tv/show/taboo-2017 EPISODE: https://watch.plex.tv/show/taboo-2017/season/1/episode/1 MOVIE: https://watch.plex.tv/movie/the-longest-yard """ ALIASES = ("plextv",) @staticmethod @click.command(name="PLEX", short_help="https://watch.plex.tv/", help=__doc__) @click.argument("title", type=str) @click.pass_context def cli(ctx: Context, **kwargs: Any) -> PLEX: return PLEX(ctx, **kwargs) def __init__(self, ctx: Context, title: str): self.title = title super().__init__(ctx) def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None: super().authenticate(cookies, credential) self.session.headers.update( { "accept": "application/json", "x-plex-client-identifier": str(uuid.uuid4()), "x-plex-language": "en", "x-plex-product": "Plex Mediaverse", "x-plex-provider-version": "6.5.0", } ) user = self._request("POST", self.config["endpoints"]["user"]) if not (auth_token := user.get("authToken")): raise ValueError(f"PLEX authentication failed: {user}") self.auth_token = auth_token self.session.headers.update({"x-plex-token": self.auth_token}) def search(self) -> Generator[SearchResult, None, None]: results = self._request( "GET", "https://discover.provider.plex.tv/library/search", params={ "searchTypes": "movies,tv", "searchProviders": "discover,plexAVOD,plexFAST", "includeMetadata": 1, "filterPeople": 1, "limit": 10, "query": quote(self.title), }, ) for result in results["MediaContainer"]["SearchResults"]: if "free on demand" not in result.get("title", "").lower(): continue for result in result["SearchResult"]: kind = result.get("Metadata", {}).get("type") slug = result.get("Metadata", {}).get("slug") yield SearchResult( id_=f"https://watch.plex.tv/{kind}/{slug}", title=result.get("Metadata", {}).get("title"), description=result.get("Metadata", {}).get("description"), label=kind, url=f"https://watch.plex.tv/{kind}/{slug}", ) def get_titles(self) -> Movies | Series: url_pattern = re.compile( r"^https://watch.plex.tv/" r"(?:[a-z]{2}(?:-[A-Z]{2})?/)??" r"(?Pmovie|show)/" r"(?P[\w-]+)" r"(?P(/season/\d+/episode/\d+))?" ) match = url_pattern.match(self.title) if not match: raise ValueError(f"Could not parse ID from title: {self.title}") kind, guid, url_path = (match.group(i) for i in ("type", "id", "url_path")) if kind == "show": if url_path is not None: path = urlparse(self.title).path url = re.sub(r"/[a-z]{2}(?:-[A-Z]{2})?/", "/", path) episode = self._episode(url) return Series(episode) episodes = self._series(guid) return Series(episodes) elif kind == "movie": movie = self._movie(guid) return Movies(movie) else: raise ValueError(f"Could not parse content type from title: {self.title}") def get_tracks(self, title: Movie | Episode) -> Tracks: dash_media = next((x for x in title.data.get("Media", []) if x.get("protocol", "").lower() == "dash"), None) if not dash_media: hls_media = next((x for x in title.data.get("Media", []) if x.get("protocol", "").lower() == "hls"), None) media = dash_media or hls_media if not media: raise ValueError("Failed to find either DASH or HLS media") manifest = DASH if dash_media else HLS media_key = media.get("id") has_drm = media.get("drm") if has_drm: manifest_url = ( self.config["endpoints"]["base_url"] + self.config["endpoints"]["manifest_drm"].format(media_key, self.auth_token) ) title.data["license_url"] = ( self.config["endpoints"]["base_url"] + self.config["endpoints"]["license"].format(media_key, self.auth_token) ) else: manifest_url = ( self.config["endpoints"]["base_url"] + self.config["endpoints"]["manifest_clear"].format(media_key, self.auth_token) ) title.data["license_url"] = None tracks = manifest.from_url(manifest_url, self.session).to_tracks(language="en") return tracks def get_chapters(self, title: Movie | Episode) -> Chapters: if not (markers := title.data.get("Marker")): try: metadata = self._request( "POST", "/playQueues", params={ "uri": self.config["endpoints"]["provider"] + title.data.get("key"), "type": "video", "continuous": "1", }, ) markers = next(( x.get("Marker") for x in metadata.get("MediaContainer", {}).get("Metadata", []) if x.get("key") == title.data.get("key")), []) except Exception as e: self.log.debug("Failed to fetch markers: %s", e) return Chapters() if not markers: return Chapters() chapters = [] for cue in markers: if cue.get("startTimeOffset", 0) > 0: chapters.append(Chapter(name=cue.get("type", "").title(), timestamp=cue.get("startTimeOffset"))) return Chapters(chapters) def get_widevine_service_certificate(self, **_: Any) -> str: return None def get_widevine_license(self, *, challenge: bytes, title: Movie | Episode, track: Any) -> bytes | str | None: if license_url := title.data.get("license_url"): r = self.session.post(url=license_url, data=challenge) if r.status_code != 200: raise ConnectionError(r.text) return r.content return None # Service specific def _fetch_season(self, url: str) -> list: return self._request("GET", url).get("MediaContainer", {}).get("Metadata", []) def _series(self, guid: str) -> list[Episode]: data = self._request("GET", f"/library/metadata/show:{guid}") meta_key = data.get("MediaContainer", {}).get("Metadata", [])[0].get("key") if not meta_key: raise ValueError("Failed to find metadata for title") series = self._request("GET", f"{self.config['endpoints']['base_url']}/{meta_key}") seasons = [ self.config["endpoints"]["base_url"] + item.get("key") for item in series.get("MediaContainer", {}).get("Metadata", []) if item.get("type") == "season" ] if not seasons: raise ValueError("Failed to find seasons for title") with ThreadPoolExecutor(max_workers=10) as executor: results = list(executor.map(self._fetch_season, seasons)) episodes = [ Episode( id_=episode.get("ratingKey"), service=self.__class__, name=episode.get("title"), season=int(episode.get("parentIndex", 0)), number=int(episode.get("index", 0)), title=re.sub(r"\s*\(\d{4}\)", "", episode.get("grandparentTitle", "")), # year=episode.get("year"), data=episode, ) for season in results for episode in season if episode.get("type") == "episode" ] return episodes def _movie(self, guid: str) -> Movie: data = self._request("GET", f"/library/metadata/movie:{guid}") movie = data.get("MediaContainer", {}).get("Metadata", [])[0] if not movie: raise ValueError(f"Could not find any data for ID {guid}") movies = [ Movie( id_=movie.get("ratingKey"), service=self.__class__, name=movie.get("title"), year=movie.get("year"), data=movie, ) ] return movies def _episode(self, path: str) -> Episode: data = self._request("GET", self.config["endpoints"]["screen"] + path) meta_key = data.get("actions", [])[0].get("data", {}).get("key") if not meta_key: raise ValueError("Failed to find metadata for title") metadata = self._request( "POST", "/playQueues", params={ "uri": self.config["endpoints"]["provider"] + meta_key, "type": "video", "continuous": "1", }, ) episode = next((x for x in metadata.get("MediaContainer", {}).get("Metadata", []) if x.get("key") == meta_key), None) if not episode: raise ValueError("Failed to find metadata for title") episodes = [ Episode( id_=episode.get("ratingKey"), service=self.__class__, name=episode.get("title"), season=int(episode.get("parentIndex", 0)), number=int(episode.get("index", 0)), title=re.sub(r"\s*\(\d{4}\)", "", episode.get("grandparentTitle", "")), # year=episode.get("year"), data=episode, ) ] return episodes def _request(self, method: str, endpoint: str, **kwargs: Any) -> Any[dict | str]: url = urljoin(self.config["endpoints"]["base_url"], endpoint) prep = self.session.prepare_request(Request(method, url, **kwargs)) response = self.session.send(prep) if response.status_code not in (200, 201, 426): raise ConnectionError(f"{response.text}") try: return json.loads(response.content) except json.JSONDecodeError: return response.text