"""DRM module is used to handle DRM operations with clock skew correction. Currently the only DRM operation is generating the Sec-MS-GEC token value used in all API requests to Microsoft Edge's online text-to-speech service.""" import hashlib import secrets from datetime import datetime as dt from datetime import timezone as tz from typing import Dict, Optional import aiohttp from .constants import TRUSTED_CLIENT_TOKEN from .exceptions import SkewAdjustmentError WIN_EPOCH = 11644473600 S_TO_NS = 1e9 class DRM: """ Class to handle DRM operations with clock skew correction. """ clock_skew_seconds: float = 0.0 @staticmethod def adj_clock_skew_seconds(skew_seconds: float) -> None: """ Adjust the clock skew in seconds in case the system clock is off. This method updates the `clock_skew_seconds` attribute of the DRM class to the specified number of seconds. Args: skew_seconds (float): The number of seconds to adjust the clock skew to. Returns: None """ DRM.clock_skew_seconds += skew_seconds @staticmethod def get_unix_timestamp() -> float: """ Gets the current timestamp in Unix format with clock skew correction. Returns: float: The current timestamp in Unix format with clock skew correction. """ return dt.now(tz.utc).timestamp() + DRM.clock_skew_seconds @staticmethod def parse_rfc2616_date(date: str) -> Optional[float]: """ Parses an RFC 2616 date string into a Unix timestamp. This function parses an RFC 2616 date string into a Unix timestamp. Args: date (str): RFC 2616 date string to parse. Returns: Optional[float]: Unix timestamp of the parsed date string, or None if parsing failed. """ try: return ( dt.strptime(date, "%a, %d %b %Y %H:%M:%S %Z") .replace(tzinfo=tz.utc) .timestamp() ) except ValueError: return None @staticmethod def handle_client_response_error(e: aiohttp.ClientResponseError) -> None: """ Handle a client response error. This method adjusts the clock skew based on the server date in the response headers and raises a SkewAdjustmentError if the server date is missing or invalid. Args: e (Exception): The client response error to handle. Returns: None """ if e.headers is None: raise SkewAdjustmentError("No server date in headers.") from e server_date: Optional[str] = e.headers.get("Date", None) if server_date is None or not isinstance(server_date, str): raise SkewAdjustmentError("No server date in headers.") from e server_date_parsed: Optional[float] = DRM.parse_rfc2616_date(server_date) if server_date_parsed is None or not isinstance(server_date_parsed, float): raise SkewAdjustmentError( f"Failed to parse server date: {server_date}" ) from e client_date = DRM.get_unix_timestamp() DRM.adj_clock_skew_seconds(server_date_parsed - client_date) @staticmethod def generate_sec_ms_gec() -> str: """ Generates the Sec-MS-GEC token value. This function generates a token value based on the current time in Windows file time format adjusted for clock skew, and rounded down to the nearest 5 minutes. The token is then hashed using SHA256 and returned as an uppercased hex digest. Returns: str: The generated Sec-MS-GEC token value. See Also: https://github.com/rany2/edge-tts/issues/290#issuecomment-2464956570 """ # Get the current timestamp in Unix format with clock skew correction ticks = DRM.get_unix_timestamp() # Switch to Windows file time epoch (1601-01-01 00:00:00 UTC) ticks += WIN_EPOCH # Round down to the nearest 5 minutes (300 seconds) ticks -= ticks % 300 # Convert the ticks to 100-nanosecond intervals (Windows file time format) ticks *= S_TO_NS / 100 # Create the string to hash by concatenating the ticks and the trusted client token str_to_hash = f"{ticks:.0f}{TRUSTED_CLIENT_TOKEN}" # Compute the SHA256 hash and return the uppercased hex digest return hashlib.sha256(str_to_hash.encode("ascii")).hexdigest().upper() @staticmethod def generate_muid() -> str: """ Generates a random MUID. Returns: str: The generated MUID. """ return secrets.token_hex(16).upper() @staticmethod def headers_with_muid(headers: Dict[str, str]) -> Dict[str, str]: """ Returns a copy of the given headers with the MUID header added. Args: headers (dict): The original headers. Returns: dict: The headers with the MUID header added. """ combined_headers = headers.copy() assert "Cookie" not in combined_headers combined_headers["Cookie"] = f"muid={DRM.generate_muid()};" return combined_headers