import base64 import hashlib import json import re from collections.abc import Generator from datetime import datetime, timedelta from http.cookiejar import CookieJar from typing import Optional, Union import click from langcodes import Language from urllib.parse import parse_qs, urlparse from unshackle.core.constants import AnyTrack 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, Title_T, Titles_T from unshackle.core.tracks import Chapter, Subtitle, Tracks, Video class SHUD(Service): """ Service code for watch.shudder.com Version: 1.0.0 Authorization: Bearer JWT Token Security: FHD@L3 Use full URL (for example - https://watch.shudder.com/watch/927436) or title ID (for example - 927436). """ TITLE_RE = r"^(?:https?://watch\.shudder\.com/[^/]+/)?(?P\d+)" GEOFENCE = ("US", "CA", "GB", "AU", "IE", "NZ") NO_SUBTITLES = False VIDEO_RANGE_MAP = { "SDR": "sdr", "HDR10": "hdr10", "DV": "dolby_vision", } @staticmethod @click.command(name="SHUD", short_help="https://watch.shudder.com") @click.argument("title", type=str) @click.option("-m", "--movie", is_flag=True, default=False, help="Specify if it's a movie") @click.option("-d", "--device", type=str, default="web", help="Select device from the config file") @click.pass_context def cli(ctx, **kwargs): return SHUD(ctx, **kwargs) def __init__(self, ctx, title, movie, device): super().__init__(ctx) self.title = title self.movie = movie self.device = device self.cdm = ctx.obj.cdm # Track request overrides based on device/CDM capabilities if any(r != Video.Range.SDR for r in self.track_request.ranges): self.track_request.codecs = [Video.Codec.HEVC] if self.cdm and self.cdm.security_level == 3: self.track_request.codecs = [Video.Codec.AVC] self.track_request.ranges = [Video.Range.SDR] if self.config is None: raise Exception("Config is missing!") profile_name = ctx.parent.params.get("profile") self.profile = profile_name or "default" self.license_data = {} self.realm = "dce.shudder" self.api_key = self.config["api_key"] def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: super().authenticate(cookies, credential) # Set required headers for all requests self.session.headers.update({ "User-Agent": self.config["client"][self.device]["user_agent"], "Accept": "application/json, text/plain, */*", "Accept-Language": "en-US", "Accept-Encoding": "gzip, deflate, br, zstd", "Referer": "https://watch.shudder.com/", "Content-Type": "application/json", "x-api-key": self.api_key, "app": "dice", "x-app-var": self.config["client"][self.device]["app_version"], "Origin": "https://watch.shudder.com", "Connection": "keep-alive", }) # Handle credential-based auth (email/password) if credential: self.log.info("Authenticating with credentials") # First get init tokens init_params = { "lk": "language", "pk": "subTitleLanguage,subtitlePreferenceMode,subtitlePreferenceMap,audioLanguage,autoAdvance,pluginAccessTokens,videoBackgroundAutoPlay", "readLicences": "true", "countEvents": "LIVE", "menuTargetPlatform": "WEB", "readIconStore": "ENABLED", "readUserProfiles": "true" } init_resp = self.session.get( url=self.config["endpoints"]["init"], params=init_params ) init_resp.raise_for_status() init_data = init_resp.json() # Login with credentials login_resp = self.session.post( url=self.config["endpoints"]["login"], headers={ "Authorization": f"Bearer {init_data.get('authentication', {}).get('authorisationToken', '')}", "Realm": self.realm, }, json={ "id": credential.username, "secret": credential.password, } ) login_resp.raise_for_status() login_data = login_resp.json() self.auth_token = login_data.get("authorisationToken") self.refresh_token = login_data.get("refreshToken") if not self.auth_token: raise ValueError("Authentication failed - no token received") self.session.headers.update({ "Authorization": f"Bearer {self.auth_token}", "Realm": self.realm, }) self.log.info("Authentication successful") return if cookies: self.log.info("Authenticating with cookies") for cookie in cookies: if cookie.name == "auth_token": self.auth_token = cookie.value self.session.headers.update({ "Authorization": f"Bearer {self.auth_token}", "Realm": self.realm, }) return raise ValueError("No valid auth_token cookie found") raise EnvironmentError("Service requires Credentials or Cookies for Authentication.") def search(self) -> Generator[SearchResult, None, None]: """Search for titles on Shudder""" search_resp = self.session.get( url=self.config["endpoints"]["search"], params={ "query": self.title, "timezone": self.config.get("timezone", "UTC"), } ) search_resp.raise_for_status() search_data = search_resp.json() cards = [] for element in search_data.get("elements", []): if element.get("$type") != "cardList": continue cards.extend(element.get("attributes", {}).get("cards", [])) for card in cards: attrs = card.get("attributes", {}) action = attrs.get("action", {}) route = action.get("data", {}) if action.get("type") == "route" else {} if not route: continue content_type = str(route.get("type", "")).upper() if content_type not in ("VOD", "SERIES"): continue raw_id = str(route.get("id", "")) if not raw_id: continue # "VOD#877410" -> "877410" # "SERIES#3311" -> "3311" title_id = raw_id.split("#", 1)[-1].strip() if not title_id: continue is_series = content_type == "SERIES" yield SearchResult( id_=title_id, title=route.get("title", ""), label="SERIES" if is_series else "MOVIE", url=f"https://watch.shudder.com/{'series' if is_series else 'watch'}/{title_id}", ) def _parse_title_input(self) -> tuple[str, Optional[str], Optional[str]]: """ Returns: (title_id, kind, season_id) kind: - "watch" for movie/episode URLs like /watch/927436 - "series" for series URLs like /series/3713?seasonId=33510 - None for raw numeric ids """ raw = str(self.title).strip() if raw.isdigit(): return raw, None, None parsed = urlparse(raw) if parsed.scheme and parsed.netloc: parts = [p for p in parsed.path.split("/") if p] kind = parts[0].lower() if parts else None title_id = parts[1] if len(parts) > 1 else None season_id = parse_qs(parsed.query).get("seasonId", [None])[0] if title_id and title_id.isdigit(): return title_id, kind, season_id match = re.match(self.TITLE_RE, raw) if not match: raise ValueError(f"Invalid Shudder title: {raw}") return match.group("title_id"), None, None def _build_manifest_payload(self, video_id: Union[str, int]) -> dict: return { "mediaCapabilities": [ { "protocols": ["HLS", "DASH"], "audioCodecs": ["aac"], "videoCodecs": ["h264", "hevc"], }, { "keySystem": "WIDEVINE", "robustness": "software", "protocols": ["HLS", "DASH"], "audioCodecs": ["aac"], "encryptionMode": ["CBC", "CTR"], "videoCodecs": ["h264"], }, ], "macros": { "CM-APP-NAME": "Website", "CM-APP-VERSION": self.config["client"][self.device]["app_version"], "CM-DVC-DNT": "0", "CM-DVC-H": "1200", "CM-DVC-W": "1920", "CM-DVC-LANG": "en-US", "CM-DVC-OS": "14", "CM-DVC-TYPE": "2", "CM-WEB-MBL": "0", "CM-WEB-PAGE": f"/video/{video_id}", "CM-CST-TCF": "", "CM-CST-USP": "", "CM-DVC-ATS": "", }, } def _get_video_metadata(self, video_id: str) -> Optional[dict]: resp = self.session.post( url=self.config["endpoints"]["video"].format(video_id=video_id), params={"includePlaybackDetails": "URL", "displayGeoblocked": "HIDE"}, json=self._build_manifest_payload(video_id), ) if resp.status_code in (404, 405): return None resp.raise_for_status() return resp.json() def _get_series_view(self, series_id: str, season_id: Optional[str] = None) -> dict: """ Supports both config styles: 1. full templated URL: https://.../api/v1/view?type=series&id={series_id}&timezone=UTC 2. base URL: https://.../api/v1/view """ endpoint = self.config["endpoints"]["series_view"] params = {} if "{series_id}" in endpoint: url = endpoint.format(series_id=series_id) else: url = endpoint params.update({ "type": "series", "id": series_id, "timezone": self.config.get("timezone", "UTC"), }) if season_id: params["seasonId"] = season_id resp = self.session.get(url=url, params=params or None) resp.raise_for_status() return resp.json() def _get_series_element(self, data: dict) -> dict: for element in data.get("elements", []): if element.get("$type") == "series": return element.get("attributes", {}) return {} def _get_season_bucket(self, data: dict) -> dict: for element in data.get("elements", []): if element.get("$type") != "bucket": continue attrs = element.get("attributes", {}) if attrs.get("tab") == "season" or attrs.get("type") == "season": return attrs return {} def _extract_series_description(self, data: dict) -> str: for element in data.get("elements", []): if element.get("$type") != "hero": continue for item in element.get("attributes", {}).get("content", []): if item.get("$type") == "textblock": text = item.get("attributes", {}).get("text") if text: return text return "" def _extract_series_year(self, data: dict) -> Optional[int]: for element in data.get("elements", []): if element.get("$type") != "hero": continue for item in element.get("attributes", {}).get("content", []): if item.get("$type") != "tagList": continue for tag in item.get("attributes", {}).get("tags", []): text = str(tag.get("attributes", {}).get("text", "")).strip() if re.fullmatch(r"\d{4}", text): return int(text) return None @staticmethod def _parse_episode_label(label: str, fallback_number: int) -> tuple[int, str]: label = (label or "").strip() if not label: return fallback_number, f"Episode {fallback_number}" m = re.match(r"^E(?P\d+)\s*[-:]\s*(?P.+)$", label, re.I) if m: return int(m.group("number")), m.group("name").strip() m = re.match(r"^Episode\s+(?P\d+)\s*[-:]\s*(?P.+)$", label, re.I) if m: return int(m.group("number")), m.group("name").strip() return fallback_number, label def _get_series_titles(self, series_id: str, preferred_season_id: Optional[str] = None) -> Series: """ Important: The /view response usually contains episode items only for the selected season. So we fetch the initial page, then request each season explicitly with seasonId=... """ page = self._get_series_view(series_id, preferred_season_id) series_element = self._get_series_element(page) season_bucket = self._get_season_bucket(page) metadata = page.get("metadata", {}) series_title = ( metadata.get("pageTitle") or series_element.get("series", {}).get("title") or "" ) series_description = self._extract_series_description(page) series_year = self._extract_series_year(page) seasons = series_element.get("seasons", {}).get("items", []) if not seasons: raise ValueError(f"No seasons found for series {series_id}") initial_season_id = str( season_bucket.get("seasonId") or season_bucket.get("id") or series_element.get("seasonId") or metadata.get("currentSeason", {}).get("seasonId") or "" ) cached_items = {} if initial_season_id: cached_items[initial_season_id] = season_bucket.get("items", []) built_episodes = [] seen_episode_ids = set() for season_index, season in enumerate(seasons, start=1): season_id = str(season.get("id")) season_number = season.get("seasonNumber") if season_number is None: m = re.search(r"(\d+)", str(season.get("title", ""))) season_number = int(m.group(1)) if m else season_index else: season_number = int(season_number) items = cached_items.get(season_id) if items is None: season_page = self._get_series_view(series_id, season_id) season_bucket = self._get_season_bucket(season_page) items = season_bucket.get("items", []) if not items: self.log.warning(f"No episode items returned for series {series_id}, season {season_number}") continue for fallback_ep_num, item in enumerate(items, start=1): episode_id = str(item["id"]) if episode_id in seen_episode_ids: continue seen_episode_ids.add(episode_id) episode_number, episode_name = self._parse_episode_label( item.get("title", ""), fallback_ep_num, ) built_episodes.append(( season_number, episode_number, Episode( id_=episode_id, service=self.__class__, title=series_title, season=season_number, number=episode_number, name=episode_name, year=series_year, language=Language.get("en"), data={ **item, "series_id": int(series_id), "series_title": series_title, "series_description": series_description, "season_id": season.get("id"), "season_title": season.get("title"), "season_number": season_number, "episode_number": episode_number, }, ), )) if not built_episodes: raise ValueError(f"No episodes found for series {series_id}") return Series([ episode for _, _, episode in sorted(built_episodes, key=lambda x: (x[0], x[1])) ]) def get_titles(self) -> Titles_T: """Get movie or series metadata""" title_id, kind, season_id = self._parse_title_input() self.title = title_id # Explicit /series/... URL -> go straight to series handling if not self.movie and kind == "series": return self._get_series_titles(title_id, season_id) # Try movie/video manifest first metadata = self._get_video_metadata(title_id) # If manifest lookup fails, try series view if metadata is None: if not self.movie: self.log.info(f"Manifest lookup failed for {title_id}, trying series view") return self._get_series_titles(title_id, season_id) raise ValueError(f"Title {title_id} not found") if metadata.get("contentDownload", {}).get("permission") == "DISALLOWED": self.log.warning(f"Download not permitted for title {title_id}") content_type = str(metadata.get("type", "")).upper() # Movie path if self.movie or content_type in ("VOD", "MOVIE"): return Movies([ Movie( id_=metadata["id"], service=self.__class__, name=metadata.get("title", ""), description=metadata.get("description", metadata.get("longDescription", "")), year=int(metadata.get("productionYear", 0)) if metadata.get("productionYear") else None, language=Language.get("en"), data=metadata, ) ]) # Direct episode ids are not ideal without the parent series context if "SEASON" in content_type or "EPISODE" in content_type: raise ValueError( "Direct episode IDs are not supported yet. " "Use the series URL or series id instead." ) # Fallback to series handling return self._get_series_titles(title_id, season_id) def get_tracks(self, title: Title_T) -> Tracks: """Fetch and parse manifest tracks""" def _fetch_variant( title: Title_T, codec: Optional[Video.Codec], range_: Video.Range, ) -> Tracks: vcodec_str = "hevc" if codec == Video.Codec.HEVC else "h264" range_str = range_.name video_format = self.VIDEO_RANGE_MAP.get(range_str, "sdr") self.log.info(f" + Fetching {vcodec_str.upper()} {range_str} manifest") # Build media capabilities payload media_capabilities = [ { "protocols": ["HLS", "DASH"], "audioCodecs": ["aac"], "videoCodecs": [vcodec_str], } ] # Add DRM capabilities for encrypted streams if codec: media_capabilities.append({ "keySystem": "WIDEVINE", "robustness": "software", "protocols": ["HLS", "DASH"], "audioCodecs": ["aac"], "encryptionMode": ["CBC", "CTR"], "videoCodecs": [vcodec_str], }) # Build macros for request macros = { "CM-APP-NAME": "Website", "CM-APP-VERSION": self.config["client"][self.device]["app_version"], "CM-DVC-DNT": "0", "CM-DVC-H": "1080", "CM-DVC-W": "1920", "CM-DVC-LANG": "en-US", "CM-DVC-OS": "14", "CM-DVC-TYPE": "2", "CM-WEB-MBL": "0", f"CM-WEB-PAGE": f"/video/{title.id}", } # Inside _fetch_variant() in get_tracks(): manifest_resp = self.session.post( url=self.config["endpoints"]["manifest"].format(video_id=title.id), params={"includePlaybackDetails": "URL", "displayGeoblocked": "HIDE"}, json={ "mediaCapabilities": media_capabilities, # Same as above "macros": macros, # Same as above, update CM-WEB-PAGE with title.id } ) manifest_resp.raise_for_status() manifest_data = manifest_resp.json() # Extract stream URL and DRM info streams = manifest_data.get("streams", []) if not streams: raise ValueError("No streams available for this title") stream = streams[0] # Take first available stream stream_url = stream.get("url") if not stream_url: raise ValueError("No stream URL found in manifest") # Store DRM/license data for later use drm = stream.get("drm", {}) if drm: self.license_data = { "url": drm.get("url", self.config["endpoints"]["widevine_license"]), "jwtToken": drm.get("jwtToken", ""), "encryptionMode": drm.get("encryptionMode", "CBC"), "keySystems": drm.get("keySystems", []), } # Parse manifest based on protocol if "m3u8" in stream_url.lower(): tracks = HLS.from_url(url=stream_url, session=self.session).to_tracks(language=title.language) else: tracks = DASH.from_url(url=stream_url, session=self.session).to_tracks(language=title.language) # Apply video range to tracks range_enum = { "hdr10": Video.Range.HDR10, "dolby_vision": Video.Range.DV, }.get(video_format, Video.Range.SDR) for video in tracks.videos: video.range = range_enum # Filter audio tracks (remove clear/unencrypted if DRM present) if drm: tracks.audio = [ track for track in tracks.audio if "clear" not in str(track.data).lower() ] # Fix channel counts for track in tracks.audio: if track.channels == 6.0: track.channels = 5.1 # Check for descriptive audio label = track.data.get("label", "").lower() if isinstance(track.data, dict) else "" if "audio description" in label or "descriptive" in label: track.descriptive = True return tracks return self._get_tracks_for_variants(title, _fetch_variant) def get_chapters(self, title: Title_T) -> list[Chapter]: """Extract chapter markers if available""" chapters = [] # Check for skip markers in title data skip_markers = title.data.get("skipMarkers", []) for marker in skip_markers: marker_type = marker.get("type", "").lower() start = marker.get("start", marker.get("offset")) end = marker.get("end") if marker_type == "intro" and start is not None: chapters.append(Chapter(timestamp=int(start), name="Opening")) if end: chapters.append(Chapter(timestamp=int(end))) elif marker_type == "credits" and start is not None: chapters.append(Chapter(timestamp=int(start), name="Credits")) return chapters def get_widevine_service_certificate(self, **_: any) -> str: """Return Widevine service certificate if configured""" return self.config.get("certificate", "") def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: """Request Widevine license from Shudder's DRM server""" license_url = self.license_data.get("url") or self.config["endpoints"]["widevine_license"] if not license_url: raise ValueError("Widevine license endpoint not configured") # Build license request headers headers = { "User-Agent": self.config["client"][self.device]["user_agent"], "Accept": "*/*", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br, zstd", "Origin": "https://watch.shudder.com", "Referer": "https://watch.shudder.com/", "Connection": "keep-alive", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "cross-site", } # Add DRM info header for Widevine drm_info = {"system": "com.widevine.alpha"} headers["X-DRM-INFO"] = base64.b64encode(json.dumps(drm_info).encode()).decode() # Add authorization if we have JWT token jwt_token = self.license_data.get("jwtToken") if jwt_token: headers["Authorization"] = f"Bearer {jwt_token}" elif hasattr(self, "auth_token"): headers["Authorization"] = f"Bearer {self.auth_token}" # Send license request response = self.session.post( url=license_url, data=challenge, headers=headers, ) response.raise_for_status() # Handle JSON or binary license response try: license_data = response.json() # Shudder may return license in different fields return license_data.get("license") or license_data.get("data") or response.content except ValueError: return response.content