2026-10-18 16:34:43 +02:00
|
|
|
package provider
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/binary"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/gorilla/websocket"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Edge TTS constants. The protocol is the well-known "Read Aloud" protocol
|
|
|
|
|
// of Microsoft Edge, reverse-engineered by edge-tts. State of the
|
|
|
|
|
// implementation (2026): DRM token Sec-MS-GEC v2 (CS64 + obfuscated SHA-256
|
|
|
|
|
// variant, see edgedrm.go) + MUID cookie; otherwise the known WebSocket
|
|
|
|
|
// message format (speech.config + SSML, binary frames with a 2-byte header
|
|
|
|
|
// length, turn.end). If Microsoft changes it, the synthesis paths return
|
|
|
|
|
// clear errors hinting at a protocol change instead of failing silently.
|
2026-10-18 16:34:43 +02:00
|
|
|
const (
|
2026-10-24 11:15:35 +02:00
|
|
|
// DefaultEndpointEdge is the production WebSocket endpoint (path without
|
|
|
|
|
// query; token/ConnectionId/Sec-MS-GEC are added in requestURL).
|
2026-10-18 16:34:43 +02:00
|
|
|
DefaultEndpointEdge = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// EnvEdgeEndpoint overrides the endpoint (tests/corporate servers).
|
2026-10-18 16:34:43 +02:00
|
|
|
EnvEdgeEndpoint = "TALK_EDGE_ENDPOINT"
|
|
|
|
|
|
|
|
|
|
edgeTrustedClientToken = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
|
|
|
|
|
edgeSecMSGECVersion = "2-143.0.3650.75"
|
|
|
|
|
edgeOrigin = "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold"
|
|
|
|
|
edgeUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"
|
|
|
|
|
edgeOutputFormat = "audio-24khz-48kbitrate-mono-mp3" // mp3!
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// edgeIOTimeout bounds individual read/write operations and (if the caller
|
|
|
|
|
// provides no deadline) the whole synthesis.
|
2026-10-18 16:34:43 +02:00
|
|
|
const edgeIOTimeout = 20 * time.Second
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// edgeClockSkew corrects local clock drift: on 403 the provider reads the
|
|
|
|
|
// Date header of the response and adjusts the tick calculation (one retry,
|
|
|
|
|
// like the edge-tts reference).
|
2026-10-18 16:34:43 +02:00
|
|
|
var edgeClockSkew atomic.Int64
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Edge is the free third tier of the chain: Microsoft's "Read Aloud"
|
|
|
|
|
// endpoint, no key needed. Synthesize opens a WebSocket connection, sends
|
|
|
|
|
// speech.config + the SSML request and collects the binary audio frames
|
|
|
|
|
// until turn.end. Return format: MP3 (24 kHz mono; the audio sink decodes
|
|
|
|
|
// the format).
|
2026-10-18 16:34:43 +02:00
|
|
|
type Edge struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
// Endpoint is the WebSocket URL (ws:// or wss://), empty = default.
|
2026-10-18 16:34:43 +02:00
|
|
|
Endpoint string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Voices is the config mapping voice.edge.<lang> = id; empty = neutral
|
|
|
|
|
// repo defaults (de-DE-KatjaNeural / en-US-AriaNeural).
|
2026-10-18 16:34:43 +02:00
|
|
|
Voices map[string]string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Dialer is the WebSocket dialer; nil = websocket.DefaultDialer.
|
2026-10-18 16:34:43 +02:00
|
|
|
Dialer *websocket.Dialer
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
pinned string // --voice pin (VoicePinner)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// NewEdge builds the provider from the chain configuration: endpoint from
|
|
|
|
|
// TALK_EDGE_ENDPOINT > [providers.edge].endpoint > default.
|
2026-10-18 16:34:43 +02:00
|
|
|
func NewEdge(cfg ChainConfig) *Edge {
|
|
|
|
|
return &Edge{
|
|
|
|
|
Endpoint: endpointFrom(cfg, "edge", EnvEdgeEndpoint, DefaultEndpointEdge),
|
|
|
|
|
Voices: cfg.Voices["edge"],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Name implements Provider.
|
2026-10-18 16:34:43 +02:00
|
|
|
func (*Edge) Name() string { return "edge" }
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// PinVoice implements VoicePinner (CLI flag --voice).
|
2026-10-18 16:34:43 +02:00
|
|
|
func (p *Edge) PinVoice(id string) { p.pinned = id }
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Available implements Provider: Edge only needs an endpoint, deliberately
|
|
|
|
|
// without a network call (reachability is determined by Synthesize, on
|
|
|
|
|
// whose error the chain falls back).
|
2026-10-18 16:34:43 +02:00
|
|
|
func (p *Edge) Available(ctx context.Context) bool { return p.Endpoint != "" }
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Synthesize implements Provider. Return format: MP3.
|
2026-10-18 16:34:43 +02:00
|
|
|
func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
|
|
|
|
if text == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("edge: empty text")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
voice := resolveVoice("edge", lang, p.pinned, p.Voices)
|
|
|
|
|
if voice == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("edge: no voice configured for language %q", lang)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
|
|
|
|
var cancel context.CancelFunc
|
|
|
|
|
ctx, cancel = context.WithTimeout(ctx, edgeIOTimeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
conn, wsURL, err := p.dialWithSkewRetry(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
defer conn.Close()
|
|
|
|
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
messages := [][]byte{
|
|
|
|
|
[]byte(edgeSpeechConfigMsg(now)),
|
|
|
|
|
[]byte(edgeSynthesisHeaders(now, edgeRequestID()) + edgeSSML(voice, text)),
|
|
|
|
|
}
|
|
|
|
|
for _, msg := range messages {
|
|
|
|
|
if err := conn.SetWriteDeadline(time.Now().Add(edgeIOTimeout)); err != nil {
|
|
|
|
|
return nil, protocolErr(err)
|
|
|
|
|
}
|
|
|
|
|
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
|
|
|
|
return nil, protocolErr(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var audio bytes.Buffer
|
|
|
|
|
for {
|
|
|
|
|
if err := conn.SetReadDeadline(time.Now().Add(edgeIOTimeout)); err != nil {
|
|
|
|
|
return nil, protocolErr(err)
|
|
|
|
|
}
|
|
|
|
|
msgType, msg, err := conn.ReadMessage()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, protocolErr(err)
|
|
|
|
|
}
|
|
|
|
|
switch msgType {
|
|
|
|
|
case websocket.TextMessage:
|
|
|
|
|
if strings.Contains(string(msg), "Path:turn.end") {
|
|
|
|
|
if audio.Len() == 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("edge: server sent turn.end without audio frames; protocol changed? (%s)", redactURL(wsURL))
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
return audio.Bytes(), nil
|
|
|
|
|
}
|
|
|
|
|
case websocket.BinaryMessage:
|
|
|
|
|
headerText, payload, ok := splitEdgeBinary(msg)
|
|
|
|
|
if ok && strings.Contains(headerText, "Path:audio") {
|
|
|
|
|
audio.Write(payload)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// dialWithSkewRetry establishes the connection; on 403 (clock drift) the
|
|
|
|
|
// server timestamp is adopted and the dial is retried ONCE.
|
2026-10-18 16:34:43 +02:00
|
|
|
func (p *Edge) dialWithSkewRetry(ctx context.Context) (*websocket.Conn, string, error) {
|
|
|
|
|
conn, wsURL, resp, err := p.dial(ctx)
|
|
|
|
|
if err == nil {
|
|
|
|
|
return conn, wsURL, nil
|
|
|
|
|
}
|
|
|
|
|
if resp != nil && resp.StatusCode == http.StatusForbidden {
|
|
|
|
|
if serverDate := resp.Header.Get("Date"); serverDate != "" {
|
|
|
|
|
if t, perr := http.ParseTime(serverDate); perr == nil {
|
|
|
|
|
edgeClockSkew.Store(t.Unix() - time.Now().Unix())
|
|
|
|
|
conn, wsURL, resp, err = p.dial(ctx)
|
|
|
|
|
if err == nil {
|
|
|
|
|
return conn, wsURL, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if resp != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, wsURL, fmt.Errorf("edge: WebSocket connection to %s failed (HTTP %d; network/timeout, or the Edge TTS protocol changed?): %w",
|
2026-10-18 16:34:43 +02:00
|
|
|
redactURL(wsURL), resp.StatusCode, err)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, wsURL, fmt.Errorf("edge: WebSocket connection to %s failed; network/timeout, or the Edge TTS protocol changed: %w",
|
2026-10-18 16:34:43 +02:00
|
|
|
redactURL(wsURL), err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// dial opens the connection according to the protocol: query with token,
|
|
|
|
|
// ConnectionId, Sec-MS-GEC (v2) and version; headers with Origin, Edge
|
|
|
|
|
// user agent and MUID cookie.
|
2026-10-18 16:34:43 +02:00
|
|
|
func (p *Edge) dial(ctx context.Context) (*websocket.Conn, string, *http.Response, error) {
|
|
|
|
|
wsURL, err := p.requestURL(time.Now())
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, "", nil, err
|
|
|
|
|
}
|
|
|
|
|
dialer := p.Dialer
|
|
|
|
|
if dialer == nil {
|
|
|
|
|
dialer = websocket.DefaultDialer
|
|
|
|
|
}
|
|
|
|
|
header := http.Header{}
|
|
|
|
|
header.Set("Origin", edgeOrigin)
|
|
|
|
|
header.Set("User-Agent", edgeUserAgent)
|
|
|
|
|
header.Set("Pragma", "no-cache")
|
|
|
|
|
header.Set("Cache-Control", "no-cache")
|
|
|
|
|
header.Set("Accept-Language", "en-US,en;q=0.9")
|
|
|
|
|
header.Set("Cookie", "muid="+edgeMUID())
|
|
|
|
|
conn, resp, err := dialer.DialContext(ctx, wsURL, header)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, wsURL, resp, err
|
|
|
|
|
}
|
|
|
|
|
return conn, wsURL, nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// requestURL adds the protocol query parameters and validates the scheme
|
|
|
|
|
// (ws/wss).
|
2026-10-18 16:34:43 +02:00
|
|
|
func (p *Edge) requestURL(now time.Time) (string, error) {
|
|
|
|
|
if p.Endpoint == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
return "", fmt.Errorf("edge: no endpoint configured")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
u, err := url.Parse(p.Endpoint)
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return "", fmt.Errorf("edge: endpoint %q unreadable: %w", p.Endpoint, err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if u.Scheme != "ws" && u.Scheme != "wss" {
|
2026-10-24 11:15:35 +02:00
|
|
|
return "", fmt.Errorf("edge: endpoint %q must be ws:// or wss:// (is %q)", p.Endpoint, u.Scheme)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
q := u.Query()
|
|
|
|
|
q.Set("TrustedClientToken", edgeTrustedClientToken)
|
|
|
|
|
q.Set("ConnectionId", edgeRequestID())
|
|
|
|
|
q.Set("Sec-MS-GEC", edgeSecMSGEC(now))
|
|
|
|
|
q.Set("Sec-MS-GEC-Version", edgeSecMSGECVersion)
|
|
|
|
|
u.RawQuery = q.Encode()
|
|
|
|
|
return u.String(), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// edgeSecMSGEC produces the DRM token Sec-MS-GEC v2: Windows filetime ticks
|
|
|
|
|
// (100 ns, 5-minute grid, clock correction) + trusted client token →
|
|
|
|
|
// "<CS64>-<SHA256 variant>" (both upper-case hex, see edgedrm.go).
|
2026-10-18 16:34:43 +02:00
|
|
|
func edgeSecMSGEC(now time.Time) string {
|
|
|
|
|
secs := now.Unix() + edgeClockSkew.Load() + drmWinEpoch
|
2026-10-24 11:15:35 +02:00
|
|
|
secs -= secs % 300 // 5-minute grid
|
2026-10-18 16:34:43 +02:00
|
|
|
ticks := uint64(secs) * 10_000_000
|
|
|
|
|
strToHash := fmt.Appendf(nil, "%d%s", ticks, edgeTrustedClientToken)
|
|
|
|
|
digest := drmSHA256Digest(strToHash)
|
|
|
|
|
cs0, cs1 := drmCS64(strToHash, digest)
|
|
|
|
|
|
|
|
|
|
var cs64Buf [8]byte
|
|
|
|
|
cs64Buf[0] = byte(cs0)
|
|
|
|
|
cs64Buf[1] = byte(cs0 >> 8)
|
|
|
|
|
cs64Buf[2] = byte(cs0 >> 16)
|
|
|
|
|
cs64Buf[3] = byte(cs0 >> 24)
|
|
|
|
|
cs64Buf[4] = byte(cs1)
|
|
|
|
|
cs64Buf[5] = byte(cs1 >> 8)
|
|
|
|
|
cs64Buf[6] = byte(cs1 >> 16)
|
|
|
|
|
cs64Buf[7] = byte(cs1 >> 24)
|
|
|
|
|
|
|
|
|
|
var shaBuf [32]byte
|
|
|
|
|
for i, w := range digest {
|
|
|
|
|
shaBuf[i*4] = byte(w >> 24)
|
|
|
|
|
shaBuf[i*4+1] = byte(w >> 16)
|
|
|
|
|
shaBuf[i*4+2] = byte(w >> 8)
|
|
|
|
|
shaBuf[i*4+3] = byte(w)
|
|
|
|
|
}
|
|
|
|
|
return strings.ToUpper(hex.EncodeToString(cs64Buf[:])) + "-" + strings.ToUpper(hex.EncodeToString(shaBuf[:]))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// protocolErr turns read/write/connection aborts into clear "protocol
|
|
|
|
|
// changed?" errors; Edge is an undocumented endpoint and changes should be
|
|
|
|
|
// diagnosable immediately.
|
2026-10-18 16:34:43 +02:00
|
|
|
func protocolErr(err error) error {
|
2026-10-24 11:15:35 +02:00
|
|
|
return fmt.Errorf("edge: communication failed; network/timeout, or the Edge TTS protocol changed: %w", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// splitEdgeBinary splits a binary server message: the first 2 bytes
|
|
|
|
|
// (big-endian) give the length of the header block (CRLF lines such as
|
|
|
|
|
// "Path:audio"), followed by the audio payload.
|
2026-10-18 16:34:43 +02:00
|
|
|
func splitEdgeBinary(msg []byte) (header string, payload []byte, ok bool) {
|
|
|
|
|
if len(msg) < 2 {
|
|
|
|
|
return "", nil, false
|
|
|
|
|
}
|
|
|
|
|
headerLen := int(binary.BigEndian.Uint16(msg[0:2]))
|
|
|
|
|
if 2+headerLen > len(msg) {
|
|
|
|
|
return "", nil, false
|
|
|
|
|
}
|
|
|
|
|
return string(msg[2 : 2+headerLen]), msg[2+headerLen:], true
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// edgeSpeechConfigMsg is the first client message: header block + JSON body
|
|
|
|
|
// with output format and metadata options (sentence boundaries on, word
|
|
|
|
|
// boundaries off; reference default).
|
2026-10-18 16:34:43 +02:00
|
|
|
func edgeSpeechConfigMsg(now time.Time) string {
|
|
|
|
|
return fmt.Sprintf(
|
|
|
|
|
"X-Timestamp:%s\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n"+
|
|
|
|
|
`{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"true","wordBoundaryEnabled":"false"},"outputFormat":%q}}}}`,
|
|
|
|
|
edgeTimestamp(now), edgeOutputFormat)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// edgeSynthesisHeaders is the header block of the SSML request. The
|
|
|
|
|
// trailing Z on the timestamp is reference behaviour ("Microsoft Edge
|
|
|
|
|
// bug"), deliberately carried along.
|
2026-10-18 16:34:43 +02:00
|
|
|
func edgeSynthesisHeaders(now time.Time, requestID string) string {
|
|
|
|
|
return fmt.Sprintf(
|
|
|
|
|
"X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n",
|
|
|
|
|
requestID, edgeTimestamp(now))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// edgeSSML builds the speech request (xml:lang fixed to "en-US" like the
|
|
|
|
|
// reference; the text is stripped of control characters and XML-escaped).
|
2026-10-18 16:34:43 +02:00
|
|
|
func edgeSSML(voice, text string) string {
|
|
|
|
|
return fmt.Sprintf(
|
|
|
|
|
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"+
|
|
|
|
|
"<voice name='%s'>"+
|
|
|
|
|
"<prosody pitch='+0Hz' rate='+0%%' volume='+0%%'>%s</prosody>"+
|
|
|
|
|
"</voice></speak>",
|
|
|
|
|
voice, edgeEscape(text))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// edgeEscape removes unsupported control characters (reference:
|
|
|
|
|
// remove_incompatible_characters) and then escapes & < >.
|
2026-10-18 16:34:43 +02:00
|
|
|
func edgeEscape(s string) string {
|
|
|
|
|
clean := make([]byte, 0, len(s))
|
|
|
|
|
for i := 0; i < len(s); i++ {
|
|
|
|
|
c := s[i]
|
|
|
|
|
if c <= 8 || (c >= 11 && c <= 12) || (c >= 14 && c <= 31) {
|
|
|
|
|
c = ' '
|
|
|
|
|
}
|
|
|
|
|
clean = append(clean, c)
|
|
|
|
|
}
|
|
|
|
|
esc := make([]byte, 0, len(clean))
|
|
|
|
|
for _, c := range clean {
|
|
|
|
|
switch c {
|
|
|
|
|
case '&':
|
|
|
|
|
esc = append(esc, "&"...)
|
|
|
|
|
case '<':
|
|
|
|
|
esc = append(esc, "<"...)
|
|
|
|
|
case '>':
|
|
|
|
|
esc = append(esc, ">"...)
|
|
|
|
|
default:
|
|
|
|
|
esc = append(esc, c)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return string(esc)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// edgeTimestamp formats the timestamp in the edge-tts format.
|
2026-10-18 16:34:43 +02:00
|
|
|
func edgeTimestamp(now time.Time) string {
|
|
|
|
|
return now.Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)")
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// edgeRequestID produces a 32-character hex ID (UUID v4 without dashes).
|
2026-10-18 16:34:43 +02:00
|
|
|
func edgeRequestID() string {
|
|
|
|
|
var b [16]byte
|
|
|
|
|
if _, err := rand.Read(b[:]); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
// Fallback: time based; the ID only has to be unique per request.
|
2026-10-18 16:34:43 +02:00
|
|
|
now := time.Now().UnixNano()
|
|
|
|
|
for i := 0; i < 16; i++ {
|
|
|
|
|
b[i] = byte(now >> (uint(i%8) * 8))
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
b[6] = (b[6] & 0x0f) | 0x40 // UUID version
|
|
|
|
|
b[8] = (b[8] & 0x3f) | 0x80 // variant
|
2026-10-18 16:34:43 +02:00
|
|
|
return hex.EncodeToString(b[:])
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// edgeMUID produces the random MUID for the cookie (32 hex, upper case).
|
2026-10-18 16:34:43 +02:00
|
|
|
func edgeMUID() string {
|
|
|
|
|
var b [16]byte
|
|
|
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
|
|
|
return edgeRequestID()
|
|
|
|
|
}
|
|
|
|
|
return strings.ToUpper(hex.EncodeToString(b[:]))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// redactURL masks the query parameters (they contain the DRM token and
|
|
|
|
|
// IDs) in error messages; the URL itself is enough for diagnosis.
|
2026-10-18 16:34:43 +02:00
|
|
|
func redactURL(raw string) string {
|
|
|
|
|
u, err := url.Parse(raw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return raw
|
|
|
|
|
}
|
|
|
|
|
u.RawQuery = "…"
|
|
|
|
|
return u.String()
|
|
|
|
|
}
|