mirror of
https://github.com/rany2/edge-tts
synced 2024-11-21 17:29:07 +00:00
Add support for clock adjustment for Sec-MS-GEC token (#309)
This should help when a user might have his clock skewed by more than 5 minutes. The server allows for a bit more than ~5 minutes of skew. Signed-off-by: rany <rany2@riseup.net>
This commit is contained in:
parent
16c973bec8
commit
dc8ac2ea7a
2
mypy.ini
2
mypy.ini
@ -21,5 +21,5 @@ warn_unreachable = True
|
|||||||
strict_equality = True
|
strict_equality = True
|
||||||
strict = True
|
strict = True
|
||||||
|
|
||||||
[mypy-edge_tts.list_voices]
|
[mypy-edge_tts.voices]
|
||||||
disallow_any_decorated = False
|
disallow_any_decorated = False
|
||||||
|
@ -4,14 +4,16 @@ __init__ for edge_tts
|
|||||||
|
|
||||||
from . import exceptions
|
from . import exceptions
|
||||||
from .communicate import Communicate
|
from .communicate import Communicate
|
||||||
from .list_voices import VoicesManager, list_voices
|
|
||||||
from .submaker import SubMaker
|
from .submaker import SubMaker
|
||||||
from .version import __version__
|
from .version import __version__, __version_info__
|
||||||
|
from .voices import VoicesManager, list_voices
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Communicate",
|
"Communicate",
|
||||||
"SubMaker",
|
"SubMaker",
|
||||||
"VoicesManager",
|
|
||||||
"exceptions",
|
"exceptions",
|
||||||
|
"__version__",
|
||||||
|
"__version_info__",
|
||||||
|
"VoicesManager",
|
||||||
"list_voices",
|
"list_voices",
|
||||||
]
|
]
|
||||||
|
@ -27,8 +27,8 @@ from xml.sax.saxutils import escape
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
import certifi
|
import certifi
|
||||||
|
|
||||||
from .constants import WSS_HEADERS, WSS_URL
|
from .constants import SEC_MS_GEC_VERSION, WSS_HEADERS, WSS_URL
|
||||||
from .drm import generate_sec_ms_gec_token, generate_sec_ms_gec_version
|
from .drm import DRM
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
NoAudioReceived,
|
NoAudioReceived,
|
||||||
UnexpectedResponse,
|
UnexpectedResponse,
|
||||||
@ -367,8 +367,8 @@ class Communicate:
|
|||||||
trust_env=True,
|
trust_env=True,
|
||||||
timeout=self.session_timeout,
|
timeout=self.session_timeout,
|
||||||
) as session, session.ws_connect(
|
) as session, session.ws_connect(
|
||||||
f"{WSS_URL}&Sec-MS-GEC={generate_sec_ms_gec_token()}"
|
f"{WSS_URL}&Sec-MS-GEC={DRM.generate_sec_ms_gec()}"
|
||||||
f"&Sec-MS-GEC-Version={generate_sec_ms_gec_version()}"
|
f"&Sec-MS-GEC-Version={SEC_MS_GEC_VERSION}"
|
||||||
f"&ConnectionId={connect_id()}",
|
f"&ConnectionId={connect_id()}",
|
||||||
compress=15,
|
compress=15,
|
||||||
proxy=self.proxy,
|
proxy=self.proxy,
|
||||||
@ -498,8 +498,16 @@ class Communicate:
|
|||||||
|
|
||||||
# Stream the audio and metadata from the service.
|
# Stream the audio and metadata from the service.
|
||||||
for self.state["partial_text"] in self.texts:
|
for self.state["partial_text"] in self.texts:
|
||||||
async for message in self.__stream():
|
try:
|
||||||
yield message
|
async for message in self.__stream():
|
||||||
|
yield message
|
||||||
|
except aiohttp.ClientResponseError as e:
|
||||||
|
if e.status != 403:
|
||||||
|
raise
|
||||||
|
|
||||||
|
DRM.handle_client_response_error(e)
|
||||||
|
async for message in self.__stream():
|
||||||
|
yield message
|
||||||
|
|
||||||
async def save(
|
async def save(
|
||||||
self,
|
self,
|
||||||
|
@ -10,6 +10,7 @@ VOICE_LIST = f"https://{BASE_URL}/voices/list?trustedclienttoken={TRUSTED_CLIENT
|
|||||||
|
|
||||||
CHROMIUM_FULL_VERSION = "130.0.2849.68"
|
CHROMIUM_FULL_VERSION = "130.0.2849.68"
|
||||||
CHROMIUM_MAJOR_VERSION = CHROMIUM_FULL_VERSION.split(".", maxsplit=1)[0]
|
CHROMIUM_MAJOR_VERSION = CHROMIUM_FULL_VERSION.split(".", maxsplit=1)[0]
|
||||||
|
SEC_MS_GEC_VERSION = f"1-{CHROMIUM_FULL_VERSION}"
|
||||||
BASE_HEADERS = {
|
BASE_HEADERS = {
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
f" (KHTML, like Gecko) Chrome/{CHROMIUM_MAJOR_VERSION}.0.0.0 Safari/537.36"
|
f" (KHTML, like Gecko) Chrome/{CHROMIUM_MAJOR_VERSION}.0.0.0 Safari/537.36"
|
||||||
|
@ -1,29 +1,131 @@
|
|||||||
"""This module contains functions for generating the Sec-MS-GEC and Sec-MS-GEC-Version tokens."""
|
"""DRM module for handling DRM operations with clock skew correction."""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime as dt
|
||||||
|
from datetime import timezone as tz
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from .constants import CHROMIUM_FULL_VERSION, TRUSTED_CLIENT_TOKEN
|
import aiohttp
|
||||||
|
|
||||||
|
from .constants import TRUSTED_CLIENT_TOKEN
|
||||||
|
from .exceptions import SkewAdjustmentError
|
||||||
|
|
||||||
|
WIN_EPOCH = 11644473600
|
||||||
|
S_TO_NS = 1e9
|
||||||
|
|
||||||
|
|
||||||
def generate_sec_ms_gec_token() -> str:
|
class DRM:
|
||||||
"""Generates the Sec-MS-GEC token value.
|
"""
|
||||||
|
Class to handle DRM operations with clock skew correction.
|
||||||
|
"""
|
||||||
|
|
||||||
See: https://github.com/rany2/edge-tts/issues/290#issuecomment-2464956570"""
|
clock_skew_seconds: float = 0.0
|
||||||
|
|
||||||
# Get the current time in Windows file time format (100ns intervals since 1601-01-01)
|
@staticmethod
|
||||||
ticks = int((datetime.now(timezone.utc).timestamp() + 11644473600) * 10000000)
|
def adj_clock_skew_seconds(skew_seconds: float) -> None:
|
||||||
|
"""
|
||||||
|
Adjust the clock skew in seconds in case the system clock is off.
|
||||||
|
|
||||||
# Round down to the nearest 5 minutes (3,000,000,000 * 100ns = 5 minutes)
|
This method updates the `clock_skew_seconds` attribute of the DRM class
|
||||||
ticks -= ticks % 3_000_000_000
|
to the specified number of seconds.
|
||||||
|
|
||||||
# Create the string to hash by concatenating the ticks and the trusted client token
|
Args:
|
||||||
str_to_hash = f"{ticks}{TRUSTED_CLIENT_TOKEN}"
|
skew_seconds (float): The number of seconds to adjust the clock skew to.
|
||||||
|
|
||||||
# Compute the SHA256 hash and return the uppercased hex digest
|
Returns:
|
||||||
return hashlib.sha256(str_to_hash.encode("ascii")).hexdigest().upper()
|
None
|
||||||
|
"""
|
||||||
|
DRM.clock_skew_seconds += skew_seconds
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_unix_timestamp() -> float:
|
||||||
|
"""
|
||||||
|
Gets the current timestamp in Windows file time format with clock skew correction.
|
||||||
|
|
||||||
def generate_sec_ms_gec_version() -> str:
|
Returns:
|
||||||
"""Generates the Sec-MS-GEC-Version token value."""
|
float: The current timestamp in Windows file time format.
|
||||||
return f"1-{CHROMIUM_FULL_VERSION}"
|
"""
|
||||||
|
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 Windows file time 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()
|
||||||
|
@ -1,20 +1,28 @@
|
|||||||
"""Exceptions for the Edge TTS project."""
|
"""Exceptions for the Edge TTS project."""
|
||||||
|
|
||||||
|
|
||||||
class UnknownResponse(Exception):
|
class BaseEdgeTTSException(Exception):
|
||||||
|
"""Base exception for the Edge TTS project."""
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownResponse(BaseEdgeTTSException):
|
||||||
"""Raised when an unknown response is received from the server."""
|
"""Raised when an unknown response is received from the server."""
|
||||||
|
|
||||||
|
|
||||||
class UnexpectedResponse(Exception):
|
class UnexpectedResponse(BaseEdgeTTSException):
|
||||||
"""Raised when an unexpected response is received from the server.
|
"""Raised when an unexpected response is received from the server.
|
||||||
|
|
||||||
This hasn't happened yet, but it's possible that the server will
|
This hasn't happened yet, but it's possible that the server will
|
||||||
change its response format in the future."""
|
change its response format in the future."""
|
||||||
|
|
||||||
|
|
||||||
class NoAudioReceived(Exception):
|
class NoAudioReceived(BaseEdgeTTSException):
|
||||||
"""Raised when no audio is received from the server."""
|
"""Raised when no audio is received from the server."""
|
||||||
|
|
||||||
|
|
||||||
class WebSocketError(Exception):
|
class WebSocketError(BaseEdgeTTSException):
|
||||||
"""Raised when a WebSocket error occurs."""
|
"""Raised when a WebSocket error occurs."""
|
||||||
|
|
||||||
|
|
||||||
|
class SkewAdjustmentError(BaseEdgeTTSException):
|
||||||
|
"""Raised when an error occurs while adjusting the clock skew."""
|
||||||
|
@ -9,8 +9,36 @@ from typing import Any, Dict, List, Optional
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
import certifi
|
import certifi
|
||||||
|
|
||||||
from .constants import VOICE_HEADERS, VOICE_LIST
|
from .constants import SEC_MS_GEC_VERSION, VOICE_HEADERS, VOICE_LIST
|
||||||
from .drm import generate_sec_ms_gec_token, generate_sec_ms_gec_version
|
from .drm import DRM
|
||||||
|
|
||||||
|
|
||||||
|
async def __list_voices(
|
||||||
|
session: aiohttp.ClientSession, ssl_ctx: ssl.SSLContext, proxy: Optional[str]
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Private function that makes the request to the voice list URL and parses the
|
||||||
|
JSON response. This function is used by list_voices() and makes it easier to
|
||||||
|
handle client response errors related to clock skew.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session (aiohttp.ClientSession): The aiohttp session to use for the request.
|
||||||
|
ssl_ctx (ssl.SSLContext): The SSL context to use for the request.
|
||||||
|
proxy (Optional[str]): The proxy to use for the request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: A dictionary of voice attributes.
|
||||||
|
"""
|
||||||
|
async with session.get(
|
||||||
|
f"{VOICE_LIST}&Sec-MS-GEC={DRM.generate_sec_ms_gec()}"
|
||||||
|
f"&Sec-MS-GEC-Version={SEC_MS_GEC_VERSION}",
|
||||||
|
headers=VOICE_HEADERS,
|
||||||
|
proxy=proxy,
|
||||||
|
ssl=ssl_ctx,
|
||||||
|
raise_for_status=True,
|
||||||
|
) as url:
|
||||||
|
data = json.loads(await url.text())
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
async def list_voices(*, proxy: Optional[str] = None) -> Any:
|
async def list_voices(*, proxy: Optional[str] = None) -> Any:
|
||||||
@ -20,19 +48,22 @@ async def list_voices(*, proxy: Optional[str] = None) -> Any:
|
|||||||
This pulls data from the URL used by Microsoft Edge to return a list of
|
This pulls data from the URL used by Microsoft Edge to return a list of
|
||||||
all available voices.
|
all available voices.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
proxy (Optional[str]): The proxy to use for the request.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: A dictionary of voice attributes.
|
dict: A dictionary of voice attributes.
|
||||||
"""
|
"""
|
||||||
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
|
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
|
||||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||||
async with session.get(
|
try:
|
||||||
f"{VOICE_LIST}&Sec-MS-GEC={generate_sec_ms_gec_token()}"
|
data = await __list_voices(session, ssl_ctx, proxy)
|
||||||
f"&Sec-MS-GEC-Version={generate_sec_ms_gec_version()}",
|
except aiohttp.ClientResponseError as e:
|
||||||
headers=VOICE_HEADERS,
|
if e.status != 403:
|
||||||
proxy=proxy,
|
raise
|
||||||
ssl=ssl_ctx,
|
|
||||||
) as url:
|
DRM.handle_client_response_error(e)
|
||||||
data = json.loads(await url.text())
|
data = await __list_voices(session, ssl_ctx, proxy)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user