mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
Drop null keys from every licence, server and export source, not only Widevine. dl now finds remote sessions and imports through ServerKeySource instead of private fields.
2203 lines
96 KiB
Python
2203 lines
96 KiB
Python
"""Remote service adapter for unshackle.
|
|
|
|
Implements the Service interface by proxying authenticate, get_titles,
|
|
get_tracks, get_chapters, and license methods to a remote unshackle server.
|
|
Everything else (track selection, download, decrypt, mux) runs locally.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import base64
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import random
|
|
import re
|
|
import shlex
|
|
import sys
|
|
import time
|
|
from collections import deque
|
|
from datetime import date as date_
|
|
from enum import Enum
|
|
from http.cookiejar import CookieJar
|
|
from pathlib import Path
|
|
from threading import Event, Lock, Thread, local
|
|
from typing import Any, Callable, Dict, Iterable, Iterator, Optional, Protocol, TypeVar, Union
|
|
from urllib.parse import urlencode
|
|
from uuid import UUID
|
|
|
|
import click
|
|
import requests
|
|
import urllib3
|
|
from langcodes import Language
|
|
from requests.adapters import HTTPAdapter, Retry
|
|
from rich.padding import Padding
|
|
from rich.rule import Rule
|
|
|
|
from unshackle.core.config import config
|
|
from unshackle.core.console import console, prompt_user, show_notice
|
|
from unshackle.core.constants import AnyTrack
|
|
from unshackle.core.credential import Credential
|
|
from unshackle.core.drm import BlowfishStripe, cbcs, real_kids
|
|
from unshackle.core.manifests.snapshot import match_track, restore
|
|
from unshackle.core.proxies.resolve import is_loopback
|
|
from unshackle.core.search_result import SearchResult
|
|
from unshackle.core.session import MAX_BACKOFF
|
|
from unshackle.core.titles import Title_T, Titles_T, remap_titles
|
|
from unshackle.core.titles.episode import Episode, Series
|
|
from unshackle.core.titles.movie import Movie, Movies
|
|
from unshackle.core.titles.music import SONG_FIELDS, Album, Song
|
|
from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video
|
|
from unshackle.core.tracks.attachment import Attachment
|
|
from unshackle.core.tracks.track import Track
|
|
from unshackle.core.tunnel import TunnelClient
|
|
from unshackle.core.utils import run_proxy
|
|
from unshackle.core.utils.collections import ci_get
|
|
from unshackle.core.utils.redact import redact_path, redact_text, safe_display_url
|
|
from unshackle.core.utils.service_params import service_options
|
|
from unshackle.core.vault import Vault
|
|
|
|
log = logging.getLogger("remote_service")
|
|
|
|
SENSITIVE_DATA_KEYS = ("credential", "credentials", "password", "token", "api_key")
|
|
|
|
DEFAULT_AUTH_HEADERS = ["X-Secret-Key", "X-Api-Key"]
|
|
|
|
# error kinds after which the remote session may be gone: the server restarted, or the tunnel it used closed
|
|
SESSION_LOST = ("SESSION_NOT_FOUND", "CONNECT", "TIMEOUT", "TUNNEL_CLOSED")
|
|
# what a recovering call can meet on its way, so these log only once the recovery fails
|
|
RECOVERABLE = (*SESSION_LOST, "SERVER_DRAINING")
|
|
REOPEN_WAIT = 1800.0 # seconds a lost session waits for its server to come back; covers a deploy's whole drain
|
|
|
|
_recovering = local()
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def recovering() -> Iterator[None]:
|
|
"""Mark this thread's remote calls as recoverable, so a RECOVERABLE error logs at debug level."""
|
|
_recovering.on = True
|
|
try:
|
|
yield
|
|
finally:
|
|
_recovering.on = False
|
|
|
|
|
|
def redact_secrets(text: str, data: Optional[Dict[str, Any]] = None) -> str:
|
|
"""Mask URL userinfo and any request-payload secrets before unshackle writes the text to the log."""
|
|
secrets = [v for k in SENSITIVE_DATA_KEYS if isinstance(v := (data or {}).get(k), str) and v]
|
|
return redact_text(text, secrets) or ""
|
|
|
|
|
|
class RemoteError(SystemExit):
|
|
"""A remote call that failed: logged, then exits a CLI run like ``SystemExit(1)``.
|
|
|
|
A serve relaying for a REST client catches it instead and answers with ``message``; ``kind`` is the
|
|
remote server's error code, or CONNECT / TIMEOUT / AUTH when the session could not be reached or opened.
|
|
(Not ``code``: SystemExit's ``code`` is the exit status.)
|
|
"""
|
|
|
|
def __init__(self, message: str, kind: str) -> None:
|
|
super().__init__(1)
|
|
self.message = message
|
|
self.kind = kind
|
|
quiet = getattr(_recovering, "on", False) and kind in RECOVERABLE
|
|
(log.debug if quiet else log.error)(message)
|
|
|
|
@property
|
|
def session_broke(self) -> bool:
|
|
"""Whether the remote session itself is gone, not just this one call."""
|
|
return self.kind in SESSION_LOST
|
|
|
|
|
|
class RemoteClient:
|
|
"""HTTP client for the unshackle serve API."""
|
|
|
|
def __init__(
|
|
self,
|
|
server_url: str,
|
|
api_key: str,
|
|
auth_headers: Optional[list[str]] = None,
|
|
timeout: Optional[float] = None,
|
|
) -> None:
|
|
self.server_url = server_url.rstrip("/")
|
|
self.timeout = timeout or 120
|
|
self.api_key = api_key
|
|
self.auth_headers = list(auth_headers) if auth_headers else list(DEFAULT_AUTH_HEADERS)
|
|
self._auth_header_index = 0
|
|
self._session: Optional[requests.Session] = None
|
|
self.tunnel: Optional[TunnelClient] = None
|
|
# wraps every request, so the service's CDM calls get answered while the server runs its code
|
|
self.in_flight: Callable[[str], contextlib.AbstractContextManager[Any]] = lambda _: contextlib.nullcontext()
|
|
|
|
@property
|
|
def session(self) -> requests.Session:
|
|
if self._session is None:
|
|
from unshackle.core import __version__
|
|
|
|
self._session = requests.Session()
|
|
self._session.headers["User-Agent"] = f"unshackle/{__version__}"
|
|
if self.api_key:
|
|
self._session.headers[self.auth_headers[self._auth_header_index]] = self.api_key
|
|
return self._session
|
|
|
|
def next_auth_header(self) -> bool:
|
|
"""Move the api key onto the next candidate header. False when no candidate header remains."""
|
|
if not self.api_key or self._auth_header_index + 1 >= len(self.auth_headers):
|
|
return False
|
|
session = self.session
|
|
session.headers.pop(self.auth_headers[self._auth_header_index], None)
|
|
self._auth_header_index += 1
|
|
session.headers[self.auth_headers[self._auth_header_index]] = self.api_key
|
|
return True
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
endpoint: str,
|
|
data: Optional[Dict[str, Any]] = None,
|
|
optional: bool = False,
|
|
expect: tuple[str, ...] = (),
|
|
body: Optional[bytes] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Send one request and return its JSON body; an error outside ``expect`` exits, one inside it returns.
|
|
|
|
A raw ``body`` goes as ``application/octet-stream``, and a binary answer comes back under ``"body"``.
|
|
"""
|
|
url = f"{self.server_url}{endpoint}"
|
|
payload: Dict[str, Any] = (
|
|
{"json": data} if body is None else {"data": body, "headers": {"Content-Type": "application/octet-stream"}}
|
|
)
|
|
gateway_retried = False
|
|
while True:
|
|
try:
|
|
with self.in_flight(endpoint):
|
|
resp = getattr(self.session, method)(url, timeout=self.timeout, **payload)
|
|
except requests.ConnectionError as e:
|
|
if isinstance(e.args[0] if e.args else None, urllib3.exceptions.ReadTimeoutError):
|
|
raise self.timed_out(endpoint) from None
|
|
server_url = safe_display_url(self.server_url)
|
|
raise RemoteError(f"Could not connect to remote server at {server_url}", "CONNECT") from None
|
|
except requests.Timeout:
|
|
raise self.timed_out(endpoint) from None
|
|
# servers differ on which header carries the key, so retry the rest before giving up
|
|
if resp.status_code == 401 and self.next_auth_header():
|
|
continue
|
|
# a proxy HTML page, not the server's JSON, means a dropped tunnel stream, which one retry gets past
|
|
if (
|
|
resp.status_code in (502, 503, 504)
|
|
and not gateway_retried
|
|
and "json" not in resp.headers.get("Content-Type", "")
|
|
):
|
|
gateway_retried = True
|
|
log.debug(f"HTTP {resp.status_code} from the proxy in front of the server on {endpoint}, retrying once")
|
|
time.sleep(1)
|
|
continue
|
|
break
|
|
if resp.status_code < 400 and "octet-stream" in resp.headers.get("Content-Type", ""):
|
|
return {"body": resp.content}
|
|
try:
|
|
detail = resp.json()
|
|
except ValueError:
|
|
if resp.status_code < 400:
|
|
raise
|
|
detail = {}
|
|
late_error = isinstance(detail, dict) and detail.get("status") == "error" and "error_code" in detail
|
|
if resp.status_code >= 400 or late_error:
|
|
page_title = re.search(r"<title>(.*?)</title>", resp.text, re.S) if not detail else None
|
|
fallback = f"HTTP {resp.status_code}: {page_title.group(1).strip()}" if page_title else resp.text
|
|
error_msg = redact_secrets(str(detail.get("message", fallback)), data)
|
|
# a gateway's own error page means the server behind it is down, as when it restarts
|
|
error_code = detail.get("error_code") or (
|
|
"CONNECT" if resp.status_code >= 500 and not detail else "UNKNOWN"
|
|
)
|
|
if error_code in expect:
|
|
return detail
|
|
if optional and error_code != "SESSION_NOT_FOUND":
|
|
log.debug(f"Optional endpoint {endpoint} unavailable [{error_code}]: {error_msg}")
|
|
return {}
|
|
if self.tunnel and self.tunnel.lost:
|
|
raise RemoteError(
|
|
"The tunnel to the server closed",
|
|
"TUNNEL_CLOSED",
|
|
)
|
|
raise RemoteError(f"Server error [{error_code}]: {error_msg}", error_code)
|
|
return detail
|
|
|
|
def timed_out(self, endpoint: str) -> RemoteError:
|
|
return RemoteError(
|
|
f"Request to remote server timed out after {self.timeout}s: {endpoint}",
|
|
"TIMEOUT",
|
|
)
|
|
|
|
def post(self, endpoint: str, data: Dict[str, Any], expect: tuple[str, ...] = ()) -> Dict[str, Any]:
|
|
return self.request("post", endpoint, data, expect=expect)
|
|
|
|
def post_optional(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""POST to an endpoint that an older server may not have. Empty dict when the route is missing."""
|
|
return self.request("post", endpoint, data, optional=True)
|
|
|
|
def get(self, endpoint: str) -> Dict[str, Any]:
|
|
return self.request("get", endpoint)
|
|
|
|
def get_optional(self, endpoint: str) -> Dict[str, Any]:
|
|
"""GET an endpoint that an older server may not have. Empty dict when the route is missing."""
|
|
return self.request("get", endpoint, optional=True)
|
|
|
|
def delete(self, endpoint: str) -> Dict[str, Any]:
|
|
return self.request("delete", endpoint)
|
|
|
|
|
|
def enum_get(enum_cls: type[Enum], name: Optional[str], default: Any = None) -> Any:
|
|
"""Safely get an enum value by name."""
|
|
if not name:
|
|
return default
|
|
try:
|
|
return enum_cls[name]
|
|
except KeyError:
|
|
return default
|
|
|
|
|
|
def sample_batches(samples: list[bytes]) -> Iterator[list[bytes]]:
|
|
"""Split samples into consecutive batches of at most cbcs.SAMPLE_BATCH_BYTES (a larger sample goes alone)."""
|
|
batch: list[bytes] = []
|
|
size = 0
|
|
for sample in samples:
|
|
if batch and size + len(sample) > cbcs.SAMPLE_BATCH_BYTES:
|
|
yield batch
|
|
batch, size = [], 0
|
|
batch.append(sample)
|
|
size += len(sample)
|
|
if batch:
|
|
yield batch
|
|
|
|
|
|
def base_track_kwargs(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""The shared Track constructor kwargs a serialized track carries.
|
|
|
|
An older server omits the newer keys, so each one falls back to the value a
|
|
locally-built track would have had.
|
|
"""
|
|
return {
|
|
"url": data.get("url") or "https://placeholder",
|
|
"language": Language.get(data.get("language") or "und"),
|
|
"is_original_lang": bool(data.get("is_original_lang", data.get("is_original", False))),
|
|
"descriptor": enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL),
|
|
"needs_repack": bool(data.get("needs_repack", False)),
|
|
"name": data.get("name"),
|
|
"edition": data.get("edition") or None,
|
|
"note": data["note"] if isinstance(data.get("note"), str) else None,
|
|
"id_": data.get("id"),
|
|
}
|
|
|
|
|
|
def deserialize_bitrate(data: Dict[str, Any]) -> int:
|
|
"""Exact bits/s when the server sent it, otherwise the kb/s figure scaled back up."""
|
|
exact = data.get("bitrate_bps")
|
|
if exact:
|
|
return int(exact)
|
|
return data["bitrate"] * 1000 if data.get("bitrate") else 0
|
|
|
|
|
|
def deserialize_video(data: Dict[str, Any]) -> Video:
|
|
return Video(
|
|
**base_track_kwargs(data),
|
|
codec=enum_get(Video.Codec, data.get("codec")),
|
|
range_=enum_get(Video.Range, data.get("range"), Video.Range.SDR),
|
|
bitrate=deserialize_bitrate(data),
|
|
width=data.get("width") or 0,
|
|
height=data.get("height") or 0,
|
|
fps=data.get("fps"),
|
|
scan_type=enum_get(Video.ScanType, data.get("scan_type")),
|
|
closed_captions=data.get("closed_captions") or None,
|
|
dv_compatible_bitstream=bool(data.get("dv_compatible_bitstream", False)),
|
|
hdr10plus=bool(data.get("hdr10plus", False)),
|
|
hdr10_base=bool(data.get("hdr10_base", False)),
|
|
)
|
|
|
|
|
|
def deserialize_audio(data: Dict[str, Any]) -> Audio:
|
|
joc = data.get("joc")
|
|
return Audio(
|
|
**base_track_kwargs(data),
|
|
codec=enum_get(Audio.Codec, data.get("codec")),
|
|
bitrate=deserialize_bitrate(data),
|
|
channels=data.get("channels"),
|
|
joc=joc,
|
|
extra={"atmos": True} if joc is None and data.get("atmos") else None,
|
|
descriptive=data.get("descriptive", False),
|
|
)
|
|
|
|
|
|
def deserialize_subtitle(data: Dict[str, Any]) -> Subtitle:
|
|
return Subtitle(
|
|
**base_track_kwargs(data),
|
|
codec=enum_get(Subtitle.Codec, data.get("codec")),
|
|
cc=data.get("cc", False),
|
|
sdh=data.get("sdh", False),
|
|
forced=data.get("forced", False),
|
|
)
|
|
|
|
|
|
def track_pssh(track: Any, drm_type: str) -> Optional[str]:
|
|
"""The base64 PSSH of the track's header for one DRM system; None when the track has none of it."""
|
|
for drm_obj in track.drm or []:
|
|
drm_class = drm_obj.__class__.__name__
|
|
if drm_type == "playready" and drm_class == "PlayReady":
|
|
return drm_obj.data["pssh_b64"]
|
|
if drm_type == "widevine" and drm_class == "Widevine":
|
|
return drm_obj.pssh.dumps()
|
|
return None
|
|
|
|
|
|
def reconstruct_drm(drm_list: Optional[list]) -> list:
|
|
"""Reconstruct DRM objects from serialized API data."""
|
|
if not drm_list:
|
|
return []
|
|
result: list[Any] = []
|
|
for drm_info in drm_list:
|
|
drm_type = drm_info.get("type", "")
|
|
pssh_str = drm_info.get("pssh")
|
|
if not pssh_str and drm_type != "blowfishstripe":
|
|
continue
|
|
try:
|
|
if drm_type == "blowfishstripe":
|
|
result.append(BlowfishStripe(drm_info["key"]))
|
|
elif drm_type == "widevine":
|
|
from pywidevine.pssh import PSSH as WidevinePSSH
|
|
|
|
from unshackle.core.drm import Widevine
|
|
|
|
wv_pssh = WidevinePSSH(pssh_str)
|
|
kids = drm_info.get("kids")
|
|
result.append(Widevine(pssh=wv_pssh, kid=kids[0] if kids and not wv_pssh.key_ids else None))
|
|
elif drm_type == "playready":
|
|
import base64 as b64
|
|
|
|
from pyplayready.system.pssh import PSSH as PlayReadyPSSH
|
|
|
|
from unshackle.core.drm import PlayReady
|
|
|
|
pr_pssh = PlayReadyPSSH(b64.b64decode(pssh_str))
|
|
result.append(PlayReady(pssh=pr_pssh, pssh_b64=pssh_str))
|
|
# a dropped entry can leave the track with no DRM, so never skip silently
|
|
except Exception as e:
|
|
log.warning(f"Skipping unparseable {drm_type} DRM entry from server: {e!r}")
|
|
continue
|
|
return result
|
|
|
|
|
|
def build_attachment(data: Dict[str, Any]) -> Optional[Attachment]:
|
|
"""Rebuild one attachment. A path-only attachment arrives as base64, which this writes to temp."""
|
|
kwargs = {
|
|
"name": data.get("name"),
|
|
"mime_type": data.get("mime_type"),
|
|
"description": data.get("description"),
|
|
}
|
|
if data.get("url"):
|
|
return Attachment(url=data["url"], **kwargs)
|
|
|
|
content = data.get("content")
|
|
if not content:
|
|
return None
|
|
try:
|
|
file_name = Path(str(data.get("file_name") or data.get("name") or "attachment")).name
|
|
target = config.directories.temp / "remote_attachments" / file_name
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(base64.b64decode(content))
|
|
return Attachment(path=target, **kwargs)
|
|
except (OSError, ValueError) as e:
|
|
log.warning(f"Skipping attachment {data.get('name')!r} sent by the server: {e}")
|
|
return None
|
|
|
|
|
|
def build_tracks(data: Dict[str, Any]) -> Tracks:
|
|
tracks = Tracks()
|
|
tracks.videos = [deserialize_video(v) for v in data.get("video", [])]
|
|
tracks.audio = [deserialize_audio(a) for a in data.get("audio", [])]
|
|
tracks.subtitles = [deserialize_subtitle(s) for s in data.get("subtitles", [])]
|
|
|
|
for track_data, track_obj in [
|
|
*zip(data.get("video", []), tracks.videos),
|
|
*zip(data.get("audio", []), tracks.audio),
|
|
*zip(data.get("subtitles", []), tracks.subtitles),
|
|
]:
|
|
drm_objs = reconstruct_drm(track_data.get("drm"))
|
|
if drm_objs:
|
|
track_obj.drm = drm_objs
|
|
preference = track_data.get("drm_preference")
|
|
if preference:
|
|
try:
|
|
track_obj.drm_preference = preference
|
|
except ValueError:
|
|
log.warning(f"Ignoring unknown drm_preference {preference!r} from the server for track {track_obj.id}")
|
|
tracks.attachments = [a for a in map(build_attachment, data.get("attachments", [])) if a]
|
|
return tracks
|
|
|
|
|
|
def apply_service_track_data(tracks: Tracks, data: Dict[str, Any]) -> None:
|
|
"""Overlay the service-set ``track.data`` the server sent.
|
|
|
|
Runs after the manifest re-parse so a service value wins over the parser's own for
|
|
the same key, as it does on the server.
|
|
"""
|
|
by_id = {str(t.id): t for t in list(tracks.videos) + list(tracks.audio) + list(tracks.subtitles)}
|
|
for key in ("video", "audio", "subtitles"):
|
|
for entry in data.get(key, []):
|
|
track = by_id.get(str(entry.get("id")))
|
|
extra = entry.get("data")
|
|
if track is not None and isinstance(extra, dict):
|
|
track.data.update(extra)
|
|
|
|
|
|
def apply_session_cookies(session: requests.Session, result: dict) -> None:
|
|
"""Copy the server's session cookies into ``session``, each scoped to its own domain.
|
|
|
|
An older server sends only ``session_cookies`` names and values, so those cookies go to every host.
|
|
It skips malformed entries.
|
|
"""
|
|
jar = result.get("session_cookie_jar")
|
|
if not isinstance(jar, list):
|
|
old = result.get("session_cookies")
|
|
jar = [{"name": k, "value": v} for k, v in old.items()] if isinstance(old, dict) else []
|
|
for c in jar:
|
|
if isinstance(c, dict) and isinstance(c.get("name"), str) and isinstance(c.get("value"), str):
|
|
session.cookies.set(
|
|
c["name"],
|
|
c["value"],
|
|
domain=str(c.get("domain") or ""),
|
|
path=str(c.get("path") or "/"),
|
|
secure=bool(c.get("secure")),
|
|
)
|
|
|
|
|
|
def segment_uri_filter(uris: Iterable[Any]) -> Callable[[Any], bool]:
|
|
"""Return an HLS ``OnSegmentFilter`` that drops the segments with these URIs.
|
|
|
|
A URI also matches without its query string, because a later fetch of the playlist can
|
|
sign the same segment with a new one.
|
|
"""
|
|
exact = {str(u) for u in uris}
|
|
paths = {u.split("?")[0] for u in exact}
|
|
|
|
def segment_filter(segment: Any) -> bool:
|
|
uri = str(getattr(segment, "absolute_uri", "") or "")
|
|
return bool(exact) and (uri in exact or uri.split("?")[0] in paths)
|
|
|
|
return segment_filter
|
|
|
|
|
|
EPISODE_PLACEHOLDER_NAME = re.compile(r"Episode ?#?\d+", re.IGNORECASE)
|
|
|
|
|
|
def episode_name(name: Optional[str], series_title: Optional[str]) -> Optional[str]:
|
|
"""Drop a placeholder episode name the way ``Episode.__init__`` does.
|
|
|
|
The server sends "Episode 04" for an unnamed episode, so the same filter has to run
|
|
again here, or the placeholder reaches the output file name.
|
|
"""
|
|
if not name:
|
|
return None
|
|
name = name.strip()
|
|
if EPISODE_PLACEHOLDER_NAME.match(name):
|
|
return None
|
|
if series_title and name.lower() == series_title.lower():
|
|
return None
|
|
return name
|
|
|
|
|
|
def parse_air_date(value: Any) -> Any:
|
|
"""ISO date string back to a date, as ``Episode.__init__`` does; anything else passes through."""
|
|
if isinstance(value, str):
|
|
try:
|
|
return date_.fromisoformat(value[:10])
|
|
except ValueError:
|
|
return value
|
|
return value
|
|
|
|
|
|
def apply_title_fields(title: Title_T, info: Dict[str, Any]) -> None:
|
|
"""Copy every serialized title field onto a local title.
|
|
|
|
A service can change its title inside ``get_tracks`` (a corrected original language,
|
|
a real episode name, an air date), so the client re-applies the server's view of the
|
|
title after every call that returns one.
|
|
"""
|
|
if info.get("language"):
|
|
title.language = Language.get(info["language"])
|
|
if info.get("description") is not None and isinstance(title, (Episode, Movie)):
|
|
title.description = info["description"]
|
|
if info.get("year") is not None:
|
|
title.year = info["year"]
|
|
|
|
if isinstance(title, Episode):
|
|
if info.get("series_title"):
|
|
title.title = info["series_title"]
|
|
if "name" in info:
|
|
title.name = episode_name(info["name"], title.title)
|
|
for key in ("season", "number", "part", "absolute"):
|
|
if info.get(key) is not None:
|
|
setattr(title, key, info[key])
|
|
if info.get("air_date") is not None:
|
|
title.air_date = parse_air_date(info["air_date"])
|
|
elif info.get("name"):
|
|
title.name = info["name"]
|
|
|
|
for key in ("anime", "daily", *(SONG_FIELDS if isinstance(title, Song) else ())):
|
|
if info.get(key) is not None:
|
|
setattr(title, key, info[key])
|
|
|
|
extra = {k: info[k] for k in ("date", "cover_url") if info.get(k) is not None}
|
|
if extra:
|
|
if isinstance(title.data, dict):
|
|
title.data.update(extra)
|
|
else:
|
|
title.data = extra
|
|
|
|
|
|
def build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Title_T:
|
|
svc_class = type(service_tag, (), {})
|
|
lang = Language.get(info["language"]) if info.get("language") else None
|
|
title_type = info.get("type")
|
|
title: Title_T
|
|
if title_type == "episode":
|
|
title = Episode(
|
|
id_=info.get("id", fallback_id),
|
|
service=svc_class,
|
|
title=info.get("series_title", "Unknown"),
|
|
season=info.get("season", 0),
|
|
number=info.get("number", 0),
|
|
language=lang,
|
|
)
|
|
elif title_type == "movie":
|
|
title = Movie(
|
|
id_=info.get("id", fallback_id),
|
|
service=svc_class,
|
|
name=info.get("name") or "Unknown",
|
|
language=lang,
|
|
)
|
|
elif title_type == "song":
|
|
title = Song(
|
|
id_=info.get("id", fallback_id),
|
|
service=svc_class,
|
|
name=info.get("name") or "Unknown",
|
|
artist=info.get("artist") or "Unknown",
|
|
album=info.get("album") or "Unknown",
|
|
track=info.get("track") or 1,
|
|
disc=info.get("disc") or 1,
|
|
language=lang,
|
|
album_artist=info.get("album_artist"),
|
|
release_type=info.get("release_type") or "album",
|
|
total_tracks=info.get("total_tracks"),
|
|
total_discs=info.get("total_discs"),
|
|
genre=info.get("genre"),
|
|
explicit=info.get("explicit"),
|
|
isrc=info.get("isrc"),
|
|
upc=info.get("upc"),
|
|
copyright=info.get("copyright"),
|
|
label=info.get("label"),
|
|
lyrics=info.get("lyrics"),
|
|
artwork_url=info.get("artwork_url"),
|
|
synced_lyrics=info.get("synced_lyrics"),
|
|
lyrics_suffix=info.get("lyrics_suffix"),
|
|
)
|
|
else:
|
|
raise click.ClickException(
|
|
f"The remote server sent an unknown title type {title_type!r}; unshackle versions differ"
|
|
)
|
|
apply_title_fields(title, info)
|
|
return title
|
|
|
|
|
|
def title_key(title: Title_T) -> tuple:
|
|
"""What tells a title apart from the others in one listing, other than its id: a service may vary the id per listing."""
|
|
if isinstance(title, Episode):
|
|
return (title.season, title.number, title.part)
|
|
if isinstance(title, Song):
|
|
return (title.disc, title.track)
|
|
return (getattr(title, "year", None),)
|
|
|
|
|
|
def resolve_auth_headers(svc: dict, server_name: str) -> list[str]:
|
|
"""Configured header names first, then the defaults as fallbacks."""
|
|
auth_headers = svc.get("auth_headers")
|
|
if auth_headers is None:
|
|
return list(DEFAULT_AUTH_HEADERS)
|
|
if (
|
|
not isinstance(auth_headers, list)
|
|
or not auth_headers
|
|
or not all(isinstance(h, str) and h.strip() for h in auth_headers)
|
|
):
|
|
raise click.ClickException(f"Remote service '{server_name}': 'auth_headers' must be a list of header names")
|
|
resolved = [h.strip() for h in auth_headers]
|
|
seen = {h.lower() for h in resolved}
|
|
resolved.extend(h for h in DEFAULT_AUTH_HEADERS if h.lower() not in seen)
|
|
return resolved
|
|
|
|
|
|
ANNOUNCED_SERVERS: set[str] = set()
|
|
|
|
|
|
def resolve_server(server_name: Optional[str]) -> tuple[str, str, dict]:
|
|
"""Find the server URL, the API key, and the per-service config in remote_services.
|
|
|
|
The per-service config carries the ``_auth_headers`` candidate list for RemoteClient.
|
|
"""
|
|
remote_services = config.remote_services
|
|
if not remote_services:
|
|
raise click.ClickException("No 'remote_services' in your unshackle.yaml")
|
|
|
|
if server_name:
|
|
svc = remote_services.get(server_name)
|
|
if not svc:
|
|
available = ", ".join(remote_services.keys())
|
|
raise click.ClickException(f"Remote service '{server_name}' not found, available: {available}")
|
|
services = svc.get("services", {})
|
|
services["_server_cdm"] = svc.get("server_cdm")
|
|
services["_auth_headers"] = resolve_auth_headers(svc, server_name)
|
|
services["_timeout"] = svc.get("timeout")
|
|
return svc["url"], svc.get("api_key", ""), services
|
|
|
|
if len(remote_services) == 1:
|
|
name, svc = next(iter(remote_services.items()))
|
|
if name not in ANNOUNCED_SERVERS:
|
|
ANNOUNCED_SERVERS.add(name)
|
|
log.info(f"Using remote service: {name}")
|
|
services = svc.get("services", {})
|
|
services["_server_cdm"] = svc.get("server_cdm")
|
|
services["_auth_headers"] = resolve_auth_headers(svc, name)
|
|
services["_timeout"] = svc.get("timeout")
|
|
return svc["url"], svc.get("api_key", ""), services
|
|
|
|
available = ", ".join(remote_services.keys())
|
|
raise click.ClickException(f"Multiple remote services configured, pick one with --server: {available}")
|
|
|
|
|
|
REMOTE_LIST_TTL = 300.0
|
|
REMOTE_LIST_FAILURE_TTL = 30.0
|
|
_remote_lists: Dict[Optional[str], tuple[float, Optional[list[dict]]]] = {}
|
|
_remote_list_locks: Dict[Optional[str], Lock] = {}
|
|
|
|
|
|
def remote_client(server_name: Optional[str]) -> RemoteClient:
|
|
"""A RemoteClient for one ``remote_services`` server, with its auth headers and timeout."""
|
|
url, api_key, services_config = resolve_server(server_name)
|
|
return RemoteClient(url, api_key, services_config.get("_auth_headers"), services_config.get("_timeout"))
|
|
|
|
|
|
def remote_service_list(server_name: Optional[str]) -> Optional[list[dict]]:
|
|
"""The services a ``remote_services`` server offers, cached for ``REMOTE_LIST_TTL``; None when it is unreachable.
|
|
|
|
A failure is cached for ``REMOTE_LIST_FAILURE_TTL``, so a server that is down does not stall every caller
|
|
for its timeout, and a blip does not hide its services for long. One caller fetches; the rest wait for it.
|
|
"""
|
|
|
|
def cached() -> Optional[tuple[float, Optional[list[dict]]]]:
|
|
hit = _remote_lists.get(server_name)
|
|
ttl = REMOTE_LIST_FAILURE_TTL if hit and hit[1] is None else REMOTE_LIST_TTL
|
|
return hit if hit and time.monotonic() - hit[0] < ttl else None
|
|
|
|
if hit := cached():
|
|
return hit[1]
|
|
with _remote_list_locks.setdefault(server_name, Lock()):
|
|
if hit := cached():
|
|
return hit[1]
|
|
services: Optional[list[dict]] = None
|
|
try:
|
|
services = remote_client(server_name).get("/api/services").get("services") or []
|
|
except (Exception, SystemExit) as e:
|
|
log.warning(f"Could not list the services of remote server '{server_name}': {e}")
|
|
_remote_lists[server_name] = (time.monotonic(), services)
|
|
return services
|
|
|
|
|
|
def reset_remote_lists() -> None:
|
|
"""Forget every cached remote service list, for a config reload."""
|
|
_remote_lists.clear()
|
|
|
|
|
|
def find_remote_service(services: list[dict], name: str) -> Optional[dict]:
|
|
"""The remote service ``name`` means: a tag in any case first, then an alias, as local get_tag resolves one."""
|
|
lowered = name.lower()
|
|
return next((svc for svc in services if str(svc.get("tag", "")).lower() == lowered), None) or next(
|
|
(svc for svc in services if lowered in (str(a).lower() for a in svc.get("aliases") or [])), None
|
|
)
|
|
|
|
|
|
def remote_service_params(info: dict, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""The remote service's cli options in ``data``, read by ``service_options``."""
|
|
names = {p.get("name") for p in info.get("cli_params") or [] if p.get("kind") == "option"}
|
|
return {k: v for k, v in service_options(data, names).items() if v is not None and v is not False}
|
|
|
|
|
|
def make_remote_service(
|
|
ctx: click.Context,
|
|
server_name: Optional[str],
|
|
service_tag: str,
|
|
title_id: str,
|
|
data: Dict[str, Any],
|
|
apply_overrides: bool = True,
|
|
) -> "RemoteService":
|
|
"""A RemoteService for one ``remote_services`` server, set up the way ``dl --remote`` sets one up.
|
|
|
|
``apply_overrides`` False keeps the per-service ``remote_services.<name>.services`` overrides out of this
|
|
process's global config.
|
|
"""
|
|
url, api_key, live = resolve_server(server_name)
|
|
own = ci_get(live, service_tag)
|
|
# resolve_server hands back the live config dict; concurrent requests for other tags must not share it
|
|
tag = service_tag.lower()
|
|
services_config = {k: v for k, v in live.items() if not (isinstance(k, str) and k.lower() == tag)}
|
|
if isinstance(own, dict) and "server_cdm" in own:
|
|
own = dict(own)
|
|
services_config["_server_cdm"] = own.pop("server_cdm")
|
|
if apply_overrides and own is not None:
|
|
services_config[service_tag] = own
|
|
info = next((s for s in remote_service_list(server_name) or [] if str(s.get("tag", "")).lower() == tag), {})
|
|
services_config["_server_accounts"] = info.get("server_accounts")
|
|
services_config["_client_config"] = info.get("client_config")
|
|
services_config["_anonymous_search"] = info.get("anonymous_search")
|
|
params = remote_service_params(info, data)
|
|
return RemoteService(ctx, service_tag, title_id, url, api_key, services_config, service_params=params)
|
|
|
|
|
|
def load_credentials_for_transport(service_tag: str, profile: Optional[str]) -> Optional[Dict[str, str]]:
|
|
from unshackle.commands.dl import dl
|
|
|
|
credential = dl.get_credentials(service_tag, profile)
|
|
if credential:
|
|
result: Dict[str, str] = {"username": credential.username, "password": credential.password}
|
|
if credential.extra:
|
|
result["extra"] = credential.extra
|
|
return result
|
|
return None
|
|
|
|
|
|
def load_cookies_for_transport(service_tag: str, profile: Optional[str]) -> Optional[str]:
|
|
import zlib
|
|
|
|
from unshackle.commands.dl import dl
|
|
|
|
cookie_path = dl.get_cookie_path(service_tag, profile)
|
|
if cookie_path and cookie_path.exists():
|
|
return base64.b64encode(zlib.compress(cookie_path.read_bytes())).decode("ascii")
|
|
return None
|
|
|
|
|
|
def resolve_proxy_arg(proxy_arg: Optional[str]) -> Optional[str]:
|
|
if not proxy_arg:
|
|
return None
|
|
|
|
from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_proxy
|
|
|
|
try:
|
|
providers = initialize_proxy_providers()
|
|
return resolve_proxy(proxy_arg, providers)
|
|
except ValueError as e:
|
|
raise click.ClickException(str(e))
|
|
|
|
|
|
def resolve_remote_proxy_arg(proxy_arg: str, providers: Optional[list] = None) -> Optional[str]:
|
|
"""
|
|
Get the proxy URI to send to a server for a --proxy value.
|
|
|
|
Control D's proxy URI is a forwarder on this machine, so for a server it becomes the
|
|
profile's resolver instead (`controld://`), and a bare region query skips Control D.
|
|
"""
|
|
from unshackle.core.proxies.controld import ControlD
|
|
from unshackle.core.proxies.resolve import find_provider, initialize_proxy_providers, resolve_proxy
|
|
|
|
try:
|
|
# the providers dl or a serve already loaded, which a serve leaves empty for a key without server_proxy
|
|
providers = initialize_proxy_providers() if providers is None else providers
|
|
provider, _, query = proxy_arg.partition(":")
|
|
if provider.lower() == "surfsharkvpn" and "@" in query:
|
|
return proxy_arg
|
|
if provider.lower() == "controld" and query:
|
|
controld = find_provider(providers, "controld")
|
|
uri = controld.get_remote_proxy(query)
|
|
if not uri:
|
|
raise ValueError(f"Control D has no location for {query}")
|
|
log.info(f"Using a Control D proxy in {query}, resolved by the server")
|
|
return uri
|
|
return resolve_proxy(proxy_arg, [x for x in providers if not isinstance(x, ControlD)])
|
|
except ValueError as e:
|
|
raise click.ClickException(str(e))
|
|
|
|
|
|
def start_tunnel(client: RemoteClient) -> TunnelClient:
|
|
"""Open a tunnel that sends the server's requests out through this machine's own connection."""
|
|
headers = {k: str(v) for k, v in client.session.headers.items() if k.lower() != "connection"}
|
|
tunnel = TunnelClient(client.server_url, headers)
|
|
try:
|
|
tunnel.start()
|
|
except ConnectionError as e:
|
|
raise click.ClickException(f"--local-proxy could not open a tunnel to the server: {e}")
|
|
client.tunnel = tunnel
|
|
return tunnel
|
|
|
|
|
|
def server_proxy_arg(params: Dict[str, Any]) -> str:
|
|
"""The --proxy value to resolve for a server: the one typed when it resolved to a proxy on this machine."""
|
|
proxy = params["proxy"]
|
|
return (params.get("proxy_arg") or proxy) if is_loopback(proxy) else proxy
|
|
|
|
|
|
_CACHE_HEX_ID_RE = re.compile(r"[0-9a-f]{32,}", re.IGNORECASE)
|
|
|
|
|
|
def credential_cache_digests(credential: Credential) -> set[str]:
|
|
"""Hex digests a service may embed in cache filenames for this credential."""
|
|
digests = set()
|
|
for text in (credential.dumps(), credential.username, credential.password):
|
|
for algo in ("md5", "sha1", "sha256"):
|
|
digests.add(hashlib.new(algo, text.encode()).hexdigest())
|
|
return digests
|
|
|
|
|
|
def _contains_name(stem: str, name: str) -> bool:
|
|
"""True when the stem contains the profile name as a separator-delimited run.
|
|
|
|
Both sides casefold and normalise separators, so a profile named "us-east"
|
|
matches "tokens_us-east" and "tokens_US_EAST".
|
|
"""
|
|
norm = re.sub(r"[._-]", "_", stem.casefold())
|
|
return f"_{re.sub(r'[._-]', '_', name.casefold())}_" in f"_{norm}_"
|
|
|
|
|
|
def cache_stem_is_relevant(
|
|
stem: str, allowed_digests: set[str], active_profile: str, foreign_profiles: set[str]
|
|
) -> bool:
|
|
"""True when a cache filename provably belongs to the active remote session.
|
|
|
|
A stem fails the check when it embeds a hex digest that the active credential
|
|
does not produce, or another configured profile's name. A digest or profile
|
|
name that matches the active credential takes priority over a foreign profile
|
|
name, because region codes in cache filenames can collide with profile names.
|
|
The exception is a longer foreign name that also matches, since the active
|
|
name is then likely a prefix of it. A stem with no identity marker at all holds
|
|
service-global state, so it passes. The check does not detect a digest cut
|
|
to fewer than 32 characters.
|
|
"""
|
|
digests = [d.lower() for d in _CACHE_HEX_ID_RE.findall(stem)]
|
|
if any(d not in allowed_digests for d in digests):
|
|
return False
|
|
if digests:
|
|
return True
|
|
matched_foreign = [n for n in foreign_profiles if _contains_name(stem, n)]
|
|
if _contains_name(stem, active_profile):
|
|
return not any(len(n) > len(active_profile) for n in matched_foreign)
|
|
return not matched_foreign
|
|
|
|
|
|
def cache_key_filter(service_tag: str, profile: Optional[str]) -> Callable[[str], bool]:
|
|
"""Return a check for the cache keys that belong to the active credential or profile.
|
|
|
|
The check runs :func:`cache_stem_is_relevant` on every path segment of a cache key,
|
|
because a service may embed the credential digest or profile name in a directory name
|
|
as well as in the basename. A profile with no credentials entry uses the default one.
|
|
"""
|
|
from unshackle.commands.dl import dl
|
|
|
|
credential = dl.get_credentials(service_tag, profile)
|
|
allowed = credential_cache_digests(credential) if credential else set()
|
|
active = profile or "default"
|
|
profiles = config.credentials.get(service_tag)
|
|
if isinstance(profiles, dict) and profile and profile not in profiles:
|
|
active = "default"
|
|
foreign = set(profiles) - {active} if isinstance(profiles, dict) else set()
|
|
|
|
def relevant(key: str) -> bool:
|
|
return all(cache_stem_is_relevant(part, allowed, active, foreign) for part in key.split("/"))
|
|
|
|
return relevant
|
|
|
|
|
|
class ServerKeySource(Protocol):
|
|
"""A service whose content keys dl gets before ``prepare_drm``: a remote session or an import."""
|
|
|
|
@property
|
|
def server_cdm(self) -> bool:
|
|
"""True when the keys come from the server CDM or the export, so dl licenses with no CDM of its own."""
|
|
|
|
@property
|
|
def server_cdm_type(self) -> str:
|
|
"""``widevine`` or ``playready``: the DRM system the keys came from."""
|
|
|
|
@property
|
|
def local_cdm(self) -> Any:
|
|
"""The CDM of this machine for a track ``licenses_locally`` names, or None."""
|
|
|
|
def resolve_server_keys(self, title: Title_T) -> None:
|
|
"""Put the content keys the server or the export holds on the tracks of ``title``."""
|
|
|
|
def licenses_locally(self, track: AnyTrack) -> bool:
|
|
"""True when this machine's own CDM licenses ``track`` instead of the server."""
|
|
|
|
def vault_source(self, kid: UUID, key: str) -> Optional[Vault]:
|
|
"""The server vault when it served ``key`` for ``kid``, else None. That content key must pass a decrypt check."""
|
|
|
|
|
|
class ServerVault(Vault):
|
|
"""The server's vaults as one source seen from the client: it can only be reported against.
|
|
|
|
``decrypt_verified`` needs a Vault to hold in ``Vaults.sources`` so a server-supplied
|
|
content key gets the same decode check as a local vault key. The client never learns
|
|
which server vault answered and can neither read nor write it, so every method returns
|
|
nothing. A bad verdict goes to the server at once, so its next licence skips the pair.
|
|
"""
|
|
|
|
report_now = True
|
|
|
|
def __init__(self, service: "RemoteService") -> None:
|
|
super().__init__("server_cdm", no_push=True)
|
|
self.service = service
|
|
|
|
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
|
return None
|
|
|
|
def get_keys(self, service: str) -> Iterator[tuple[str, str]]:
|
|
return iter(())
|
|
|
|
def add_key(self, service: str, kid: Union[UUID, str], key: str) -> bool:
|
|
return False
|
|
|
|
def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int:
|
|
return 0
|
|
|
|
def get_services(self) -> Iterator[str]:
|
|
return iter(())
|
|
|
|
def flag_bad_key(self, service: str, kid: Union[UUID, str], key: str, source: str) -> None:
|
|
self.service.report_bad_key(UUID(str(kid)), key)
|
|
|
|
|
|
class RemoteService:
|
|
"""Service adapter that proxies to a remote unshackle server.
|
|
|
|
Implements the same interface dl.py's result() expects without
|
|
subclassing Service (avoids proxy/geofence setup in __init__).
|
|
"""
|
|
|
|
ALIASES: tuple[str, ...] = ()
|
|
GEOFENCE: tuple[str, ...] = ()
|
|
GEOBLOCK: tuple[str, ...] = ()
|
|
NO_SUBTITLES: bool = False
|
|
ANIME: bool = False
|
|
DAILY: bool = False
|
|
server_cdm_type: str = "widevine"
|
|
|
|
def __init__(
|
|
self,
|
|
ctx: click.Context,
|
|
service_tag: str,
|
|
title_id: str,
|
|
server_url: str,
|
|
api_key: str,
|
|
services_config: dict,
|
|
service_params: Optional[Dict[str, Any]] = None,
|
|
) -> None:
|
|
self.__class__.__name__ = service_tag
|
|
console.print(Padding(Rule(f"[rule.text]Service: {service_tag} (Remote)"), (1, 2)))
|
|
|
|
self.service_tag = service_tag
|
|
self.title_id = title_id
|
|
self.client = RemoteClient(
|
|
server_url, api_key, services_config.get("_auth_headers"), services_config.get("_timeout")
|
|
)
|
|
self.ctx = ctx
|
|
self._service_params = service_params or {}
|
|
self.log = logging.getLogger(service_tag)
|
|
self.credential: Optional[Credential] = None
|
|
self.current_region: Optional[str] = None
|
|
self.title_cache = None
|
|
self._titles: Optional[Titles_T] = None
|
|
self._tracks_by_title: Dict[str, Tracks] = {}
|
|
self._chapters_by_title: Dict[str, list] = {}
|
|
self._session_id: Optional[str] = None
|
|
self.client_licensed: set[str] = set() # track ids this machine's device licenses by challenge relay
|
|
self.server_vault_keys: Dict[UUID, str] = {}
|
|
self.server_vault = ServerVault(self)
|
|
self._segment_filters: Dict[str, Callable[[Any], bool]] = {}
|
|
self._sample_decrypt_tracks: set[str] = set()
|
|
self._log_seq = 0
|
|
self._recent_logs: deque[str] = deque(maxlen=3)
|
|
self._input_bridge: Any = None # a serve relaying for a REST client sets one, as it does for a Service
|
|
# what a login waiting on the user (a pair code, an approval link) last showed; None otherwise
|
|
self.auth_notice: Optional[str] = None
|
|
self._log_drain_lock = Lock()
|
|
self._keepalive_stop = Event()
|
|
self._reopen_lock = Lock()
|
|
self._current_title: Optional[Title_T] = None
|
|
# client title and track id -> the id a reopened session gave the same title or track
|
|
self._title_alias: Dict[str, str] = {}
|
|
self._track_alias: Dict[str, str] = {}
|
|
|
|
self._session = requests.Session()
|
|
self._session.headers.update(config.headers)
|
|
self._session.mount(
|
|
"https://",
|
|
HTTPAdapter(
|
|
max_retries=Retry(
|
|
total=5,
|
|
backoff_factor=0.2,
|
|
status_forcelist=[429, 500, 502, 503, 504],
|
|
retry_after_max=int(MAX_BACKOFF),
|
|
),
|
|
pool_maxsize=64,
|
|
pool_block=True,
|
|
),
|
|
)
|
|
self._session.mount("http://", self._session.adapters["https://"])
|
|
|
|
svc_config = services_config.get(service_tag, {})
|
|
self._server_cdm: bool | None = services_config.get("_server_cdm")
|
|
self._server_vault = False
|
|
self._server_device = False
|
|
self._local_vaults_only = False
|
|
self._server_accounts: Optional[dict] = services_config.get("_server_accounts")
|
|
self._anonymous_search = bool(services_config.get("_anonymous_search"))
|
|
# read before apply_service_config merges the server's values into the same section
|
|
own = config.services.get(service_tag) or {}
|
|
self._client_config = {k: own[k] for k in services_config.get("_client_config") or [] if k in own}
|
|
self._cdm_sessions: Dict[str, bytes] = {}
|
|
self._cdm_lock = Lock()
|
|
self._cdm_requests = 0
|
|
self._cdm_stop = Event()
|
|
self.apply_service_config(svc_config)
|
|
|
|
def apply_service_config(self, svc_config: dict) -> None:
|
|
if not svc_config:
|
|
return
|
|
config_maps = {
|
|
"cdm": ("cdm", self.service_tag),
|
|
"decryption": ("decryption_map", self.service_tag),
|
|
}
|
|
for key, (attr, tag) in config_maps.items():
|
|
if svc_config.get(key):
|
|
target = getattr(config, attr, None)
|
|
if target is None:
|
|
setattr(config, attr, {})
|
|
target = getattr(config, attr)
|
|
target[tag] = svc_config[key]
|
|
|
|
if svc_config.get("decryption"):
|
|
config.decryption = svc_config["decryption"]
|
|
|
|
extra = {k: v for k, v in svc_config.items() if k not in config_maps}
|
|
if extra:
|
|
existing = config.services.get(self.service_tag, {})
|
|
for key, value in extra.items():
|
|
if key in existing and isinstance(existing[key], dict) and isinstance(value, dict):
|
|
existing[key].update(value)
|
|
else:
|
|
existing[key] = value
|
|
config.services[self.service_tag] = existing
|
|
|
|
@property
|
|
def session(self) -> requests.Session:
|
|
return self._session
|
|
|
|
@property
|
|
def title(self) -> str:
|
|
return self.title_id
|
|
|
|
def search(self) -> Iterator[SearchResult]:
|
|
"""Search on the server, which logs in to the service itself with the login material sent here."""
|
|
params = self.ctx.parent.params if self.ctx.parent else {}
|
|
profile = params.get("profile")
|
|
data: Dict[str, Any] = {"service": self.service_tag, "query": self.title_id}
|
|
if profile:
|
|
data["profile"] = profile
|
|
if not self._anonymous_search:
|
|
data.update(self.client_login(profile))
|
|
if params.get("no_proxy"):
|
|
data["no_proxy"] = True
|
|
elif params.get("local_proxy"):
|
|
data["proxy"] = self.tunnel()
|
|
elif params.get("proxy"):
|
|
providers = getattr(self.ctx.obj, "proxy_providers", None)
|
|
data["proxy"] = resolve_remote_proxy_arg(server_proxy_arg(params), providers)
|
|
self.require_reachable_proxy(data["proxy"])
|
|
try:
|
|
response = self.client.post("/api/search", data)
|
|
finally:
|
|
if self.client.tunnel: # a search is one request, so its tunnel ends with it
|
|
self.client.tunnel.stop()
|
|
self.save_returned_cache(response.get("cache") or {}, profile)
|
|
for result in response.get("results") or []:
|
|
yield SearchResult(
|
|
result["id"], result["title"], result.get("description"), result.get("label"), result.get("url")
|
|
)
|
|
|
|
def client_login(self, profile: Optional[str]) -> Dict[str, Any]:
|
|
"""The credentials, cookies and cache to send, or nothing when the server manages the account."""
|
|
if self._server_accounts is not None:
|
|
return {}
|
|
fields = {
|
|
"credentials": load_credentials_for_transport(self.service_tag, profile),
|
|
"cookies": load_cookies_for_transport(self.service_tag, profile),
|
|
"cache": self.load_cache_files(profile),
|
|
}
|
|
return {k: v for k, v in fields.items() if v}
|
|
|
|
def require_reachable_proxy(self, proxy: Optional[str]) -> None:
|
|
"""Refuse a proxy on this machine when the server is elsewhere: the server cannot reach it."""
|
|
if proxy and is_loopback(proxy) and not is_loopback(self.client.server_url):
|
|
raise click.ClickException("--remote cannot use a proxy on this machine; use --local-proxy without --proxy")
|
|
|
|
def tunnel(self) -> str:
|
|
"""The ``tunnel:<id>`` proxy to send the server for ``--local-proxy``, opening the tunnel the first time."""
|
|
if self._server_accounts is not None:
|
|
raise click.ClickException(
|
|
f"The server logs in to {self.service_tag} with its own account, so --local-proxy does not work with it."
|
|
)
|
|
tunnel = self.client.tunnel
|
|
if tunnel is None:
|
|
tunnel = start_tunnel(self.client)
|
|
self.log.info("The server sends its requests through your connection on this machine")
|
|
return f"tunnel:{tunnel.id}"
|
|
|
|
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
self.credential = credential
|
|
profile = self.ctx.parent.params.get("profile") if self.ctx.parent else None
|
|
proxy = self.ctx.parent.params.get("proxy") if self.ctx.parent else None
|
|
no_proxy = self.ctx.parent.params.get("no_proxy", False) if self.ctx.parent else False
|
|
|
|
create_data: Dict[str, Any] = {"service": self.service_tag, "title_id": self.title_id}
|
|
|
|
if self._server_accounts is None:
|
|
create_data.update(self.client_login(profile))
|
|
else:
|
|
self.credential = None
|
|
self.log.info(
|
|
"The server manages the account for this service" + (" and ignores --profile" if profile else "")
|
|
)
|
|
|
|
client_region = None
|
|
if not no_proxy:
|
|
try:
|
|
from unshackle.core.utils.ip_info import get_ip_info
|
|
|
|
ip_info = get_ip_info(self._session, cached=True)
|
|
if ip_info and ip_info.get("country"):
|
|
client_region = ip_info["country"].lower()
|
|
except Exception:
|
|
pass
|
|
|
|
providers = getattr(self.ctx.obj, "proxy_providers", None)
|
|
local = bool(self.ctx.parent and self.ctx.parent.params.get("local_proxy"))
|
|
if local:
|
|
create_data["proxy"] = self.tunnel()
|
|
elif not no_proxy and not proxy and self._server_accounts and not self._server_accounts.get("global"):
|
|
regions = [str(r).lower() for r in self._server_accounts.get("regions") or []]
|
|
if regions and client_region and client_region not in regions:
|
|
try:
|
|
create_data["proxy"] = resolve_remote_proxy_arg(regions[0], providers)
|
|
create_data["proxy_region"] = regions[0]
|
|
self.log.info(f"Using a '{regions[0]}' proxy to match a server account ({', '.join(regions)})")
|
|
except click.ClickException:
|
|
self.log.warning(
|
|
f"No local proxy for the server's account region ({', '.join(regions)}), "
|
|
f"you are in {client_region}"
|
|
)
|
|
elif not no_proxy and proxy and self.ctx.parent:
|
|
resolved_proxy = resolve_remote_proxy_arg(server_proxy_arg(self.ctx.parent.params), providers)
|
|
if resolved_proxy:
|
|
create_data["proxy"] = resolved_proxy
|
|
query = (self.ctx.parent.params.get("proxy_query") if self.ctx.parent else None) or proxy
|
|
country = re.match(r"^(?:[a-z]+:)?([a-z]{2})\d*(?:@.*)?$", query, re.IGNORECASE)
|
|
if country:
|
|
create_data["proxy_region"] = country.group(1).lower()
|
|
self.require_reachable_proxy(create_data.get("proxy"))
|
|
local_proxy = None if no_proxy or local else (proxy or create_data.get("proxy"))
|
|
if local_proxy:
|
|
self._session.proxies["all"] = local_proxy
|
|
run_proxy.pin(self._session)
|
|
|
|
if not no_proxy and not proxy and client_region:
|
|
create_data["client_region"] = client_region
|
|
elif not no_proxy and not proxy and self._server_accounts and not self._server_accounts.get("global"):
|
|
self.log.warning("Could not detect your region; pass --proxy <country> so the server can pick an account")
|
|
|
|
if profile:
|
|
create_data["profile"] = profile
|
|
if no_proxy:
|
|
create_data["no_proxy"] = True
|
|
# Forward track selection params so the server fetches the right manifests
|
|
if self.ctx.parent:
|
|
range_ = self.ctx.parent.params.get("range_")
|
|
if range_:
|
|
create_data["range_"] = [r.name for r in range_]
|
|
vcodec = self.ctx.parent.params.get("vcodec")
|
|
if vcodec:
|
|
create_data["vcodec"] = [c.name for c in vcodec]
|
|
quality = self.ctx.parent.params.get("quality")
|
|
if quality:
|
|
create_data["quality"] = list(quality)
|
|
if self.ctx.parent.params.get("best_available"):
|
|
create_data["best_available"] = True
|
|
if self.ctx.parent.params.get("generate_dv"):
|
|
create_data["generate_dv"] = True
|
|
# Services pick the manifests to fetch from these. They go in their own
|
|
# object: flat, a service option with the same name would take them (or override them).
|
|
# An empty list is sent as-is, because only an absent key falls back to the dl default.
|
|
params = self.ctx.parent.params
|
|
dl_params: dict[str, Any] = {
|
|
key: [str(x) for x in params[key]]
|
|
for key in ("lang", "v_lang", "a_lang", "s_lang")
|
|
if params.get(key) is not None
|
|
}
|
|
if params.get("acodec") is not None:
|
|
dl_params["acodec"] = [c.name for c in params["acodec"]]
|
|
for key in ("forced_subs", "exact_lang"):
|
|
if key in params:
|
|
dl_params[key] = bool(params[key])
|
|
if dl_params:
|
|
create_data["dl_params"] = dl_params
|
|
|
|
# sent even empty: without it the server reads service options from the flat dl keys
|
|
create_data["service_params"] = self._service_params
|
|
|
|
# sent with the server CDM too: a capped server hands the tracks above its cap to this device
|
|
cdm = self.local_cdm
|
|
cdm_name = self.ctx.parent and self.ctx.parent.params.get("cdm_name")
|
|
cdm_entry = cdm is not None and self.ctx.obj.cdm_entry and self._server_cdm is None
|
|
if not (cdm_entry or cdm_name or self._server_cdm is not None) and ci_get(config.cdm, self.service_tag):
|
|
self.log.warning(
|
|
f"The cdm entry for {self.service_tag} did not load a device for this remote session, "
|
|
"so the server decides who licenses it"
|
|
)
|
|
if cdm is not None:
|
|
from unshackle.core.cdm.detect import is_playready_cdm
|
|
|
|
if cdm_entry or cdm_name:
|
|
self._server_cdm = False
|
|
if self._server_cdm is False:
|
|
create_data["server_cdm"] = False
|
|
create_data["cdm_type"] = "playready" if is_playready_cdm(cdm) else "widevine"
|
|
level = getattr(cdm, "security_level", None)
|
|
if isinstance(level, int):
|
|
create_data["cdm_security_level"] = level
|
|
create_data["cdm_relay"] = True
|
|
system_id = getattr(cdm, "system_id", None)
|
|
if isinstance(system_id, int):
|
|
create_data["cdm_system_id"] = system_id
|
|
device_type = getattr(getattr(cdm, "device_type", None), "name", None)
|
|
if device_type:
|
|
create_data["cdm_device_type"] = device_type
|
|
if self._client_config:
|
|
create_data["service_config"] = self._client_config
|
|
|
|
from unshackle.core import __code_hash__, __version__
|
|
|
|
secrets = [v for v in (create_data.get("credentials") or {}).values() if isinstance(v, str) and len(v) >= 4]
|
|
# a serve relaying for a REST client names itself; its own argv is just the serve command
|
|
client_argv = self.ctx.parent.params.get("client_argv") if self.ctx.parent else None
|
|
argv = (redact_path(redact_text(client_argv or shlex.join(sys.argv[1:]), secrets)) or "")[:3000]
|
|
create_data["client"] = {
|
|
"version": __version__,
|
|
"code_hash": __code_hash__ or None,
|
|
"platform": sys.platform,
|
|
"argv": argv,
|
|
}
|
|
|
|
try:
|
|
result = self.client.post("/api/session/create", create_data)
|
|
except BaseException:
|
|
if local and self.client.tunnel: # no session, so close() never runs: free the tunnel now
|
|
self.client.tunnel.stop()
|
|
raise
|
|
self._session_id = result["session_id"]
|
|
atexit.register(self.close)
|
|
# an older server omits the field, and the tracks response settles it
|
|
if "server_cdm" in result:
|
|
self.adopt_session_cdm(bool(result["server_cdm"]), result.get("server_cdm_max_height"))
|
|
self._server_device = bool(result.get("server_device"))
|
|
if cdm_entry and self._server_cdm is False and not self._server_device:
|
|
self.log.info(
|
|
f"The cdm config has an entry for {self.service_tag}, so your own device licenses "
|
|
"this remote session and not the server CDM"
|
|
)
|
|
vault = bool(result.get("server_vault"))
|
|
if self._server_device:
|
|
self._server_cdm = True
|
|
whose = "the server vault or yours" if vault else "your vaults only"
|
|
self.log.info(f"This session runs on the server's device: keys come from {whose}")
|
|
self._server_vault = vault and not self._server_cdm
|
|
# without the server vault grant the server refuses every key request, so none is sent
|
|
self._local_vaults_only = self._server_device and not vault
|
|
|
|
status = result.get("status", "authenticated")
|
|
if status == "authenticating":
|
|
self.poll_auth_completion()
|
|
if create_data.get("cdm_relay"):
|
|
self.client.in_flight = self.answering_cdm_calls
|
|
self.drain_server_logs()
|
|
self.start_keepalive()
|
|
|
|
@property
|
|
def local_cdm(self) -> Any:
|
|
"""The CDM device dl loaded on this machine, or None. dl may later swap its own copy for a stub."""
|
|
return self.ctx.obj.cdm if self.ctx.obj else None
|
|
|
|
@property
|
|
def local_vaults_only(self) -> bool:
|
|
"""True for a remote session on the server's device whose API key cannot read the server vault."""
|
|
return getattr(self, "_local_vaults_only", False)
|
|
|
|
def report_local_fallback(self) -> None:
|
|
"""Say the local CDM licenses because the server CDM is not granted.
|
|
|
|
Only info when the service's own cdm entry loaded that device, since the entry is the configured fallback.
|
|
"""
|
|
log = self.log.info if getattr(self.ctx.obj, "cdm_entry", False) else self.log.warning
|
|
log(
|
|
f"{self.service_tag} is not available for server CDM licensing with this API key, "
|
|
"falling back to the local CDM"
|
|
)
|
|
|
|
def adopt_session_cdm(self, server_cdm: bool, max_height: Optional[int]) -> None:
|
|
"""Take the server's choice of who licenses this remote session, and say why when a cap made it.
|
|
|
|
A remote session on the server's lent device keeps the server-CDM flow: dl checks the local vaults, then asks the
|
|
server, which answers only from its vault. ``_server_cdm`` is False on entry when the user picked the local
|
|
device, so a server that keeps its own CDM gets a warning.
|
|
"""
|
|
if getattr(self, "_server_device", False):
|
|
self._server_cdm = True
|
|
return
|
|
if self._server_cdm is False and server_cdm:
|
|
self._server_cdm = True
|
|
self.log.warning("The server CDM licenses this remote session, so your own device is not used")
|
|
return
|
|
if self._server_cdm and not server_cdm and max_height is None:
|
|
self.report_local_fallback()
|
|
self._server_cdm = server_cdm
|
|
if max_height is None:
|
|
return
|
|
message = f"The server CDM licenses up to {max_height}p for this API key"
|
|
if not server_cdm:
|
|
message += "; licensing with your own device"
|
|
elif self.local_cdm is not None:
|
|
message += "; pass a higher -q to license above it with your own device"
|
|
self.log.info(message)
|
|
|
|
def start_keepalive(self) -> None:
|
|
"""Send keep-alive requests so that a long download or mux operation does not outlive the server's idle TTL.
|
|
|
|
``GET /api/session/{session_id}`` refreshes the idle timer and reports the TTL, so the thread sends
|
|
a request at an interval of one third of it. The thread stops when ``close()`` or a reopen runs. A client
|
|
that dies stops sending requests, so the server still expires an abandoned remote session.
|
|
"""
|
|
info = self.client.get_optional(f"/api/session/{self._session_id}")
|
|
interval = max(float(info.get("expires_in") or 300) / 3, 5.0)
|
|
url = f"{self.client.server_url}/api/session/{self._session_id}"
|
|
stop = self._keepalive_stop = Event()
|
|
|
|
def ping() -> None:
|
|
while not stop.wait(interval):
|
|
try:
|
|
self.client.session.get(url, timeout=30)
|
|
except requests.RequestException:
|
|
pass
|
|
|
|
Thread(target=ping, name=f"{self.service_tag}-keepalive", daemon=True).start()
|
|
|
|
def session_call(self, call: Callable[[Optional[str]], T]) -> T:
|
|
"""Run ``call`` with the session id, and once more on a new session when the server lost the old one."""
|
|
session_id = self._session_id
|
|
if getattr(_recovering, "on", False):
|
|
return call(session_id)
|
|
try:
|
|
with recovering():
|
|
return call(session_id)
|
|
except RemoteError as e:
|
|
if not e.session_broke:
|
|
raise
|
|
lost = e
|
|
if not self.recover(session_id, lost.kind):
|
|
log.error(lost.message)
|
|
raise lost
|
|
return call(self._session_id)
|
|
|
|
def recover(self, session_id: Optional[str], kind: str) -> bool:
|
|
"""Whether a call that failed with ``kind`` can run again, on the same session or on a new one.
|
|
|
|
The same session takes the call again only after a connect error, as a timed-out call may have run.
|
|
"""
|
|
with self._reopen_lock:
|
|
if self._session_id != session_id: # another thread already reopened it
|
|
return self._session_id is not None
|
|
if session_id is None or not self.wait_for_server(accepting=False):
|
|
return False
|
|
url = f"{self.client.server_url}/api/session/{session_id}"
|
|
try:
|
|
alive = kind != "TUNNEL_CLOSED" and self.client.session.get(url, timeout=30).ok
|
|
except requests.RequestException:
|
|
alive = False
|
|
if alive:
|
|
return kind == "CONNECT"
|
|
self.log.warning("The remote session was lost, opening a new one")
|
|
return self.reopen()
|
|
|
|
def wait_for_server(self, accepting: bool) -> bool:
|
|
"""Wait until the server answers, and with ``accepting`` until it also takes new sessions. False on timeout."""
|
|
deadline = time.monotonic() + REOPEN_WAIT
|
|
delay = 1.0
|
|
warned = False
|
|
while True:
|
|
try:
|
|
health = self.client.session.get(f"{self.client.server_url}/api/health", timeout=30).json()
|
|
if health.get("status") == "ok" and not (accepting and health.get("draining")):
|
|
return True
|
|
except (requests.RequestException, ValueError, AttributeError):
|
|
pass
|
|
if time.monotonic() >= deadline:
|
|
return False
|
|
if not warned:
|
|
self.log.warning(f"Waiting for the remote server at {safe_display_url(self.client.server_url)}")
|
|
warned = True
|
|
# jittered, so the clients of a restarted server do not all log in again at the same moment
|
|
time.sleep(random.uniform(delay / 2, delay)) # nosec B311
|
|
delay = min(delay * 2, 30.0)
|
|
|
|
def reopen(self) -> bool:
|
|
"""Open a new remote session after the server lost this one, and load the current title's tracks on it again.
|
|
|
|
A track the new remote session gives another id keeps its old one here, and calls translate it.
|
|
"""
|
|
grants = (self._server_cdm, self._server_device, self._server_vault)
|
|
self._keepalive_stop.set()
|
|
self._session_id = None
|
|
if self.client.tunnel: # the server forgot the tunnel too
|
|
self.client.tunnel.stop()
|
|
self.client.tunnel = None
|
|
self._log_seq = 0
|
|
self._segment_filters.clear()
|
|
if not self.wait_for_server(accepting=True):
|
|
return False
|
|
try:
|
|
with recovering():
|
|
self.authenticate(credential=self.credential)
|
|
listing = self.client.get(f"/api/session/{self._session_id}/titles").get("titles", [])
|
|
fresh_titles = [build_title(t, self.service_tag, self.title_id) for t in listing]
|
|
for old in self._titles or []:
|
|
same = [t for t in fresh_titles if title_key(t) == title_key(old)]
|
|
if len(same) == 1 and str(same[0].id) != str(old.id):
|
|
self._title_alias[str(old.id)] = str(same[0].id)
|
|
title = self._current_title
|
|
if title is not None:
|
|
held = self._tracks_by_title.pop(str(title.id), None)
|
|
try:
|
|
fresh = list(self.get_tracks(title))
|
|
finally: # dl holds the old track objects, so the cache keeps them
|
|
if held is not None:
|
|
self._tracks_by_title[str(title.id)] = held
|
|
for track in held or []:
|
|
match = match_track(track, fresh)
|
|
if match is not None and str(match.id) != str(track.id):
|
|
self._track_alias[str(track.id)] = str(match.id)
|
|
except RemoteError as e:
|
|
if e.kind not in RECOVERABLE:
|
|
raise
|
|
return False
|
|
if (self._server_cdm, self._server_device, self._server_vault) != grants:
|
|
raise RemoteError("The new remote session licenses differently from the lost one", "GRANTS_CHANGED")
|
|
return True
|
|
|
|
def reopen_after_drain(self) -> None:
|
|
"""Close the remote session before the server's announced restart, so its drain can end, then open a new one."""
|
|
with self._reopen_lock:
|
|
self.log.info("The remote server restarts for an update, reopening the session after it")
|
|
self.close()
|
|
self._current_title = None
|
|
if not self.reopen():
|
|
raise RemoteError(f"The remote server did not come back within {REOPEN_WAIT:.0f}s", "CONNECT")
|
|
|
|
def server_draining(self) -> bool:
|
|
"""Whether the server announced a restart; asked once per title, as an episode can be shorter than the keepalive."""
|
|
try:
|
|
url = f"{self.client.server_url}/api/session/{self._session_id}"
|
|
return bool(self._session_id and self.client.session.get(url, timeout=30).json().get("draining"))
|
|
except (requests.RequestException, ValueError, AttributeError):
|
|
return False
|
|
|
|
def server_track_id(self, track_id: Any) -> str:
|
|
"""The id the current remote session knows the track with this client id by."""
|
|
return self._track_alias.get(str(track_id), str(track_id))
|
|
|
|
def drain_server_logs(self) -> None:
|
|
"""Fetch the service's server-side log records for this remote session and re-emit them locally.
|
|
|
|
The server mirrors every ``self.log`` call the service makes into a
|
|
per-session buffer. Draining after each remote call shows the client
|
|
why a step failed. A relay failure must never break the operation
|
|
itself, so this method swallows every error.
|
|
"""
|
|
if not self._session_id:
|
|
return
|
|
with self._log_drain_lock:
|
|
try:
|
|
resp = self.client.get_optional(f"/api/session/{self._session_id}/logs?since={self._log_seq}")
|
|
except (Exception, SystemExit):
|
|
return
|
|
if not resp:
|
|
return
|
|
level_names = logging.getLevelNamesMapping()
|
|
for record in resp.get("logs", []):
|
|
level = level_names.get(str(record.get("level", "INFO")), logging.INFO)
|
|
self.log.log(level, str(record.get("message", "")))
|
|
self._recent_logs.append(str(record.get("message", "")).strip())
|
|
self._log_seq = int(resp.get("last_seq", self._log_seq))
|
|
|
|
def poll_auth_completion(self, poll_interval: float = 2.0, timeout: float = 600.0) -> None:
|
|
"""Poll the server until authentication completes, handling interactive prompts.
|
|
|
|
When the server needs user input (OTP, device code, PIN), it returns
|
|
``pending_input`` with a prompt. We display it locally, collect the
|
|
response, and POST it back. The server resumes its auth flow.
|
|
"""
|
|
deadline = time.monotonic() + timeout
|
|
try:
|
|
self.poll_auth(deadline, poll_interval)
|
|
finally:
|
|
self.close_cdm_sessions()
|
|
if self.auth_notice is not None:
|
|
self.auth_notice = None
|
|
show_notice(None)
|
|
|
|
def poll_auth(self, deadline: float, poll_interval: float) -> None:
|
|
while time.monotonic() < deadline:
|
|
resp = self.client.get(f"/api/session/{self._session_id}/prompt")
|
|
status = resp.get("status")
|
|
seen = self._log_seq
|
|
self.drain_server_logs()
|
|
|
|
if status == "authenticated":
|
|
return
|
|
|
|
if status == "authenticating" and self._log_seq == seen and self._recent_logs:
|
|
notice = "\n".join(self._recent_logs)
|
|
if notice != self.auth_notice:
|
|
self.auth_notice = notice
|
|
show_notice(notice)
|
|
|
|
if status == "failed":
|
|
raise RemoteError(f"Remote auth failed: {resp.get('error', 'Authentication failed on server')}", "AUTH")
|
|
|
|
if status == "pending_input" and isinstance(resp.get("cdm_call"), dict):
|
|
self.answer_cdm_call(resp["cdm_call"], self._cdm_sessions)
|
|
continue
|
|
|
|
if status == "pending_input":
|
|
prompt = resp.get("prompt", "Enter input: ")
|
|
bridge = self._input_bridge
|
|
try:
|
|
user_response = bridge.request_input(prompt) if bridge else prompt_user(prompt)
|
|
except EOFError:
|
|
raise RemoteError("Remote auth needs interactive input but stdin is closed", "AUTH") from None
|
|
self.client.post(
|
|
f"/api/session/{self._session_id}/prompt",
|
|
{"response": user_response},
|
|
)
|
|
continue
|
|
|
|
time.sleep(poll_interval)
|
|
|
|
raise RemoteError("Remote authentication timed out", "AUTH")
|
|
|
|
def answer_cdm_call(self, call: Dict[str, Any], sessions: Dict[str, bytes]) -> None:
|
|
"""Run one of the server's CDM calls on this machine's device and post the answer."""
|
|
from unshackle.core.cdm.client_relay import answer_cdm_call
|
|
|
|
answer = answer_cdm_call(self.local_cdm, call, sessions)
|
|
self.log.debug(f"Answered a server CDM call: {call.get('op')} ({'refused' if 'error' in answer else 'ok'})")
|
|
self.client.post(f"/api/session/{self._session_id}/prompt", {"response": json.dumps(answer)})
|
|
|
|
@contextlib.contextmanager
|
|
def answering_cdm_calls(self, endpoint: str) -> Iterator[None]:
|
|
"""Answer the server's CDM calls while a remote session request runs service code.
|
|
|
|
One poller serves every concurrent request, and the local CDM sessions close when the last one ends.
|
|
"""
|
|
if not endpoint.startswith(f"/api/session/{self._session_id}/") or endpoint.partition("?")[0].endswith(
|
|
("/prompt", "/logs", "/samples")
|
|
):
|
|
yield
|
|
return
|
|
with self._cdm_lock:
|
|
self._cdm_requests += 1
|
|
if self._cdm_requests == 1:
|
|
self._cdm_stop = Event()
|
|
Thread(
|
|
target=self.poll_cdm_calls, args=(self._cdm_stop,), name=f"{self.service_tag}-cdm", daemon=True
|
|
).start()
|
|
try:
|
|
yield
|
|
finally:
|
|
with self._cdm_lock:
|
|
self._cdm_requests -= 1
|
|
if self._cdm_requests == 0:
|
|
self._cdm_stop.set()
|
|
|
|
def poll_cdm_calls(self, stop: Event, interval: float = 0.5) -> None:
|
|
url = f"{self.client.server_url}/api/session/{self._session_id}/prompt"
|
|
sessions: Dict[str, bytes] = {}
|
|
while not stop.wait(interval):
|
|
try:
|
|
call = self.client.session.get(url, timeout=30).json().get("cdm_call")
|
|
if isinstance(call, dict):
|
|
self.answer_cdm_call(call, sessions)
|
|
except (requests.RequestException, ValueError, SystemExit) as e:
|
|
self.log.debug(f"CDM call poll failed: {e}")
|
|
self.close_cdm_sessions(sessions)
|
|
|
|
def close_cdm_sessions(self, sessions: Optional[Dict[str, bytes]] = None) -> None:
|
|
"""Close the local CDM sessions that the server's calls opened; best effort."""
|
|
sessions = self._cdm_sessions if sessions is None else sessions
|
|
cdm = self.local_cdm
|
|
for session_id in sessions.values():
|
|
with contextlib.suppress(Exception):
|
|
cdm.close(session_id)
|
|
sessions.clear()
|
|
|
|
def get_titles(self) -> Titles_T:
|
|
if self._titles is not None:
|
|
return self._titles
|
|
try:
|
|
result = self.session_call(lambda sid: self.client.get(f"/api/session/{sid}/titles"))
|
|
finally:
|
|
self.drain_server_logs()
|
|
titles_list = [build_title(t, self.service_tag, self.title_id) for t in result.get("titles", [])]
|
|
first = titles_list[0] if titles_list else None
|
|
if isinstance(first, Episode):
|
|
self._titles = Series(titles_list)
|
|
elif isinstance(first, Song):
|
|
self._titles = Album(titles_list)
|
|
else:
|
|
self._titles = Movies(titles_list)
|
|
return self._titles
|
|
|
|
def get_titles_cached(self, title_id: Optional[str] = None) -> Titles_T:
|
|
"""Apply the client's local title_map to titles fetched from the remote server.
|
|
|
|
Lets users rename titles for remote services they do not have installed locally.
|
|
The server sends raw titles. The client's own ``services.<TAG>.title_map`` wins.
|
|
"""
|
|
title_map = (config.services.get(self.service_tag) or {}).get("title_map") or {}
|
|
return remap_titles(self.get_titles(), title_map)
|
|
|
|
def get_tracks(self, title: Title_T) -> Tracks:
|
|
title_id = str(title.id)
|
|
if title_id not in self._tracks_by_title and self.server_draining(): # dl has finished the title before
|
|
self.reopen_after_drain()
|
|
self._current_title = title
|
|
if title_id in self._tracks_by_title:
|
|
return self._tracks_by_title[title_id]
|
|
try:
|
|
result = self.session_call(
|
|
lambda sid: self.client.post(
|
|
f"/api/session/{sid}/tracks", {"title_id": self._title_alias.get(title_id, title_id)}
|
|
)
|
|
)
|
|
finally:
|
|
self.drain_server_logs()
|
|
tracks = build_tracks(result)
|
|
|
|
remote_title = result.get("title") or {}
|
|
if remote_title:
|
|
apply_title_fields(title, remote_title)
|
|
|
|
for k, v in result.get("session_headers", {}).items():
|
|
if k.lower() not in ("host", "content-length", "content-type"):
|
|
self._session.headers[k] = v
|
|
apply_session_cookies(self._session, result)
|
|
|
|
restore(tracks, result)
|
|
apply_service_track_data(tracks, result)
|
|
|
|
server_cdm = bool(result.get("server_cdm", False)) or getattr(self, "_server_device", False)
|
|
if self._server_cdm is None:
|
|
self._server_cdm = server_cdm
|
|
elif self._server_cdm and not server_cdm:
|
|
self._server_cdm = False
|
|
self.report_local_fallback()
|
|
self.server_cdm_type = result.get("server_cdm_type", "widevine")
|
|
self._sample_decrypt_tracks.update(result.get("sample_decrypt_tracks") or [])
|
|
|
|
for track in [*tracks.videos, *tracks.audio, *tracks.subtitles]:
|
|
if track.descriptor == Track.Descriptor.HLS:
|
|
track.OnSegmentFilter = self.remote_segment_filter(str(track.id))
|
|
|
|
self._tracks_by_title[title_id] = tracks
|
|
self._chapters_by_title[title_id] = result.get("chapters", [])
|
|
|
|
return tracks
|
|
|
|
def decrypts_samples(self, track: AnyTrack) -> bool:
|
|
return self.server_track_id(track.id) in self._sample_decrypt_tracks
|
|
|
|
def get_sample_decrypter(self, track: AnyTrack, entry: int) -> Union[bytes, cbcs.SampleDecrypter]:
|
|
"""Ask the server how `entry` decrypts. A decrypter sends the samples to the server in batches."""
|
|
|
|
def samples(body: bytes) -> Dict[str, Any]:
|
|
def call(sid: Optional[str]) -> Dict[str, Any]:
|
|
query = urlencode({"track_id": self.server_track_id(track.id), "entry": entry})
|
|
return self.client.request("post", f"/api/session/{sid}/samples?{query}", body=body)
|
|
|
|
return self.session_call(call)
|
|
|
|
answer = samples(b"")
|
|
if answer.get("key"):
|
|
return bytes.fromhex(answer["key"])
|
|
|
|
def decrypt(batches: list[bytes]) -> list[bytes]:
|
|
plaintexts: list[bytes] = []
|
|
for batch in sample_batches(batches):
|
|
frame = samples(cbcs.pack_samples(batch))["body"]
|
|
plaintexts.extend(cbcs.unpack_samples(frame))
|
|
return plaintexts
|
|
|
|
return decrypt
|
|
|
|
def remote_segment_filter(self, track_id: str) -> Callable[[Any], bool]:
|
|
"""The server's ``OnSegmentFilter`` result for one HLS track, fetched on first use.
|
|
|
|
The service keeps its filter; the client gets only the segment URIs to skip.
|
|
A server without the route returns nothing, so the client keeps every segment.
|
|
"""
|
|
|
|
def segment_filter(segment: Any) -> bool:
|
|
unwanted = self._segment_filters.get(track_id)
|
|
if unwanted is None:
|
|
server_id = self.server_track_id(track_id)
|
|
result = self.session_call(
|
|
lambda sid: self.client.post_optional(f"/api/session/{sid}/segment_filter", {"track_id": server_id})
|
|
)
|
|
unwanted = segment_uri_filter(result.get("unwanted") or [])
|
|
self._segment_filters[track_id] = unwanted
|
|
return unwanted(segment)
|
|
|
|
return segment_filter
|
|
|
|
def resolve_server_keys(self, title: Title_T) -> None:
|
|
"""Get the DRM content keys through the server CDM for all tracks on a title.
|
|
|
|
Called by dl.py between track selection and download. The server
|
|
decides which CDM device to use and tells the client through
|
|
server_cdm_type. We send track IDs and the server does the full
|
|
CDM flow, returning KID:KEY pairs.
|
|
"""
|
|
# track ids can repeat across titles, so a refusal for the last title must not carry over
|
|
self.client_licensed.clear()
|
|
if not (self._server_cdm or getattr(self, "_server_vault", False)) or self.local_vaults_only:
|
|
return
|
|
|
|
from uuid import UUID
|
|
|
|
keyed = title.tracks.videos + title.tracks.audio
|
|
if not keyed:
|
|
return
|
|
|
|
drm_type = self.server_cdm_type
|
|
if getattr(self, "_server_vault", False):
|
|
from unshackle.core.cdm.detect import is_playready_cdm
|
|
|
|
drm_type = "playready" if is_playready_cdm(self.local_cdm) else "widevine"
|
|
self.log.debug(f"Requesting server CDM keys (server_cdm_type={drm_type})")
|
|
|
|
capped: Dict[str, Any] = {}
|
|
try:
|
|
with console.status("Retrieving Remote License...", spinner="dots"):
|
|
resp = self.session_call(
|
|
lambda sid: self.client.post(
|
|
f"/api/session/{sid}/license",
|
|
{
|
|
"track_ids": [self.server_track_id(t.id) for t in keyed],
|
|
"mode": "server_cdm",
|
|
"drm_type": drm_type,
|
|
},
|
|
)
|
|
)
|
|
track_ids = [self.server_track_id(t.id) for t in keyed]
|
|
self.drain_server_logs()
|
|
keys_by_track = resp.get("keys", {})
|
|
clear_tracks = set(resp.get("clear_tracks", []))
|
|
vault_kids = set(resp.get("vault_keys", []))
|
|
for track_keys in keys_by_track.values():
|
|
self.note_vault_keys(track_keys, vault_kids)
|
|
server_drm_type = resp.get("drm_type", drm_type)
|
|
drm_types_by_track = resp.get("drm_types", {})
|
|
capped = resp.get("capped_tracks") or {}
|
|
self.server_cdm_type = server_drm_type
|
|
self.log.debug(f"Server responded with drm_type={server_drm_type}, keys for {len(keys_by_track)} track(s)")
|
|
|
|
for track in title.tracks:
|
|
server_id = self.server_track_id(track.id)
|
|
track_keys = keys_by_track.get(server_id, {})
|
|
if not track_keys:
|
|
if server_id in track_ids and server_id not in clear_tracks | set(capped):
|
|
self.log.warning(f"Server CDM returned no content keys for track {track.id}")
|
|
continue
|
|
|
|
named = {kid.hex for drm in track.drm or [] for kid in real_kids(drm)} & track_keys.keys()
|
|
track_drm_type = drm_types_by_track.get(server_id, server_drm_type)
|
|
drm_obj = self.create_drm_stub(track_drm_type, sorted(named) or list(track_keys))
|
|
for kid_hex, key_hex in track_keys.items():
|
|
drm_obj.content_keys[UUID(hex=kid_hex)] = key_hex
|
|
track.drm = [drm_obj]
|
|
self.log.debug(
|
|
f"Track {track.id}: set DRM to {drm_obj.__class__.__name__} with {len(track_keys)} key(s)"
|
|
)
|
|
key_count = sum(len(v) for v in keys_by_track.values())
|
|
if key_count:
|
|
self.log.debug(f"Server CDM resolved {key_count} key(s) using {server_drm_type.upper()}")
|
|
except Exception as e:
|
|
self.log.warning("Failed to resolve server CDM keys: %s", e)
|
|
|
|
for track in title.tracks:
|
|
# a lent-device miss still gets the local vaults, which dl reads before asking the server again
|
|
refusal = capped.get(self.server_track_id(track.id))
|
|
if refusal is not None and refusal.get("reason") != "server_device":
|
|
self.license_locally(track, refusal)
|
|
|
|
def license_locally(self, track: AnyTrack, refusal: Dict[str, Any]) -> None:
|
|
"""Hand a track the server CDM refused to license live to this machine's own device.
|
|
|
|
dl then licenses it through the challenge relay. An HLS track with no DRM yet passes, as its
|
|
media playlist names the DRM and the downloader honours ``drm_preference``.
|
|
"""
|
|
from unshackle.core.cdm.detect import is_playready_cdm
|
|
|
|
if refusal.get("reason") == "server_device":
|
|
vaults = (
|
|
"none of your vaults, and this API key cannot read the server vault"
|
|
if self.local_vaults_only
|
|
else "no vault, neither the server's nor yours"
|
|
)
|
|
raise click.ClickException(
|
|
f"The content key for {track} is in {vaults}; drop the service's server-identity option to license it locally"
|
|
)
|
|
cap = refusal.get("max_height")
|
|
if cap:
|
|
why = f"The server CDM licenses up to {cap}p for this API key"
|
|
elif getattr(self, "_server_vault", False):
|
|
why = "The server vault holds no content key for this track"
|
|
else:
|
|
why = "The server CDM refused this track"
|
|
cdm = self.local_cdm
|
|
if cdm is None:
|
|
raise click.ClickException(f"{why}, and {track} needs a local CDM to license it")
|
|
drm_class = "PlayReady" if is_playready_cdm(cdm) else "Widevine"
|
|
if track.drm and not any(d.__class__.__name__ == drm_class for d in track.drm):
|
|
height = getattr(track, "height", None)
|
|
raise click.ClickException(
|
|
f"{why}, and {track} has no {drm_class} DRM for your local CDM; pass -q {height or 'with that height'}"
|
|
)
|
|
track.drm_preference = drm_class.lower()
|
|
self.client_licensed.add(str(track.id))
|
|
self.log.info(f"{why}; licensing {track.id} with your own {drm_class} device")
|
|
|
|
@property
|
|
def server_cdm(self) -> bool:
|
|
"""True when the server CDM licenses this remote session, so dl licenses with no CDM of its own."""
|
|
return bool(self._server_cdm)
|
|
|
|
def licenses_locally(self, track: AnyTrack) -> bool:
|
|
"""True when the server handed ``track`` to this machine's own CDM to license."""
|
|
return str(track.id) in self.client_licensed
|
|
|
|
def vault_source(self, kid: UUID, key: str) -> Optional[Vault]:
|
|
"""The server vault when it served ``key`` for ``kid``, else None."""
|
|
return self.server_vault if self.server_vault_keys.get(kid) == key else None
|
|
|
|
def note_vault_keys(self, keys: Dict[str, str], vault_kids: set[str]) -> None:
|
|
"""Remember which of the served keys a server vault supplied, and forget the rest."""
|
|
for kid_hex, key in keys.items():
|
|
kid = UUID(hex=kid_hex)
|
|
if kid_hex in vault_kids:
|
|
self.server_vault_keys[kid] = key
|
|
else:
|
|
self.server_vault_keys.pop(kid, None)
|
|
|
|
def report_bad_key(self, kid: UUID, key: str) -> None:
|
|
"""Tell the server a vault key it served did not decrypt, so it flags its own row."""
|
|
try:
|
|
self.session_call(
|
|
lambda sid: self.client.post_optional(f"/api/session/{sid}/keys/bad", {"kid": kid.hex, "key": key})
|
|
)
|
|
except (Exception, RemoteError) as e:
|
|
self.log.warning(f"Could not report the bad content key to the server: {e!r}")
|
|
|
|
@staticmethod
|
|
def create_drm_stub(drm_type: str, kid_hexes: list[str]) -> Any:
|
|
"""Make a DRM object stub that matches the type the server used.
|
|
|
|
For server_cdm mode, unshackle uses this stub for display only, because it
|
|
already has the content keys. We assemble a minimal DRM object that holds
|
|
content_keys.
|
|
"""
|
|
from uuid import UUID
|
|
|
|
if drm_type == "playready":
|
|
import base64 as b64
|
|
import struct
|
|
|
|
from pyplayready.system.pssh import PSSH as PlayReadyPSSH
|
|
|
|
from unshackle.core.drm import PlayReady
|
|
|
|
kid_uuids = [UUID(hex=k) for k in kid_hexes]
|
|
kid_b64 = b64.b64encode(kid_uuids[0].bytes_le).decode()
|
|
wrm_xml = (
|
|
'<WRMHEADER xmlns="http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader" version="4.0.0.0">'
|
|
f"<DATA><PROTECTINFO><KEYLEN>16</KEYLEN><ALGID>AESCTR</ALGID></PROTECTINFO>"
|
|
f"<KID>{kid_b64}</KID></DATA></WRMHEADER>"
|
|
)
|
|
wrm_bytes = wrm_xml.encode("utf-16-le")
|
|
record_length = len(wrm_bytes)
|
|
obj_length = 4 + 2 + 2 + 2 + record_length
|
|
pr_obj = struct.pack("<IHH", obj_length, 1, 1) + struct.pack("<H", record_length) + wrm_bytes
|
|
pr_pssh = PlayReadyPSSH(pr_obj)
|
|
pssh_b64 = b64.b64encode(pr_obj).decode("ascii")
|
|
drm = PlayReady(pssh=pr_pssh, pssh_b64=pssh_b64)
|
|
for kid_uuid in kid_uuids:
|
|
if kid_uuid not in drm.kids:
|
|
drm.kids.append(kid_uuid)
|
|
return drm
|
|
else:
|
|
from pywidevine.pssh import PSSH as WvPSSH
|
|
|
|
from unshackle.core.drm import Widevine
|
|
|
|
WIDEVINE_SYSTEM_ID = UUID("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed")
|
|
dummy_pssh = WvPSSH.new(system_id=WIDEVINE_SYSTEM_ID, key_ids=[UUID(hex=k) for k in kid_hexes])
|
|
# no kid: the stub names several KIDs, and the first is not the track's own
|
|
return Widevine(pssh=dummy_pssh)
|
|
|
|
def get_chapters(self, title: Title_T) -> Chapters:
|
|
title_id = str(title.id)
|
|
if title_id not in self._chapters_by_title:
|
|
self.get_tracks(title)
|
|
raw = self._chapters_by_title.get(title_id, [])
|
|
return Chapters([Chapter(ch["timestamp"], ch.get("name")) for ch in raw])
|
|
|
|
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
|
return self.proxy_license(challenge, track, "widevine")
|
|
|
|
def get_playready_license(
|
|
self, *, challenge: bytes, title: Title_T, track: AnyTrack
|
|
) -> Optional[Union[bytes, str]]:
|
|
return self.proxy_license(challenge, track, "playready")
|
|
|
|
def get_clearkey_license(
|
|
self, *, challenge: bytes, title: Title_T, track: AnyTrack
|
|
) -> Optional[Union[bytes, str, dict]]:
|
|
return None
|
|
|
|
def get_widevine_service_certificate(
|
|
self,
|
|
*,
|
|
challenge: bytes,
|
|
title: Title_T,
|
|
track: AnyTrack,
|
|
) -> Optional[Union[bytes, str]]:
|
|
# a missing certificate is legal (services may not use one), so a failed fetch continues without one
|
|
resp = self.session_call(
|
|
lambda sid: self.client.post_optional(
|
|
f"/api/session/{sid}/license",
|
|
{
|
|
"track_id": self.server_track_id(track.id),
|
|
"challenge": base64.b64encode(challenge).decode("ascii"),
|
|
"drm_type": "widevine",
|
|
"is_certificate": True,
|
|
},
|
|
)
|
|
)
|
|
return base64.b64decode(resp["license"]) if resp.get("license") else None
|
|
|
|
def proxy_license(self, challenge: Union[bytes, str], track: AnyTrack, drm_type: str) -> bytes:
|
|
if isinstance(challenge, str):
|
|
challenge = challenge.encode("utf-8")
|
|
|
|
if self.local_vaults_only:
|
|
self.license_locally(track, {"reason": "server_device"})
|
|
if self._server_cdm and str(track.id) not in self.client_licensed:
|
|
from uuid import UUID
|
|
|
|
server_type = self.server_cdm_type
|
|
other_type = "playready" if server_type == "widevine" else "widevine"
|
|
drm_type, pssh_b64 = server_type, track_pssh(track, server_type)
|
|
if not pssh_b64:
|
|
drm_type, pssh_b64 = other_type, track_pssh(track, other_type)
|
|
refusal: Optional[Dict[str, Any]] = None
|
|
if pssh_b64:
|
|
try:
|
|
request = {"drm_type": drm_type, "mode": "server_cdm", "pssh": pssh_b64}
|
|
resp = self.session_call(
|
|
lambda sid: self.client.post(
|
|
f"/api/session/{sid}/license",
|
|
{**request, "track_id": self.server_track_id(track.id)},
|
|
expect=("SERVER_CDM_CAPPED",),
|
|
)
|
|
)
|
|
if resp.get("error_code") == "SERVER_CDM_CAPPED":
|
|
refusal = resp.get("details") or {}
|
|
self.server_cdm_type = resp.get("drm_type", server_type)
|
|
keys = resp.get("keys", {})
|
|
self.note_vault_keys(keys, set(resp.get("vault_keys", [])))
|
|
if keys and track.drm:
|
|
for drm_obj in track.drm:
|
|
if hasattr(drm_obj, "content_keys"):
|
|
for kid_hex, key_hex in keys.items():
|
|
drm_obj.content_keys[UUID(hex=kid_hex)] = key_hex
|
|
return challenge
|
|
except (Exception, RemoteError) as e:
|
|
self.log.warning("server_cdm license failed: %s", e)
|
|
else:
|
|
self.log.warning(f"Track {track.id} has no PSSH to send to the server CDM")
|
|
if refusal is not None:
|
|
# dl sees the track in client_licensed and calls again with this device's challenge
|
|
self.license_locally(track, refusal)
|
|
return challenge
|
|
|
|
pssh_b64 = track_pssh(track, drm_type)
|
|
|
|
payload = {
|
|
"challenge": base64.b64encode(challenge).decode("ascii"),
|
|
"drm_type": drm_type,
|
|
}
|
|
if pssh_b64:
|
|
payload["pssh"] = pssh_b64
|
|
|
|
try:
|
|
resp = self.session_call(
|
|
lambda sid: self.client.post(
|
|
f"/api/session/{sid}/license", {**payload, "track_id": self.server_track_id(track.id)}
|
|
)
|
|
)
|
|
finally:
|
|
self.drain_server_logs()
|
|
return base64.b64decode(resp["license"])
|
|
|
|
def on_segment_downloaded(self, track: AnyTrack, segment: Any) -> None:
|
|
pass
|
|
|
|
def on_track_downloaded(self, track: AnyTrack) -> None:
|
|
pass
|
|
|
|
def on_track_decrypted(self, track: AnyTrack, drm: Any, segment: Any = None) -> None:
|
|
pass
|
|
|
|
def on_track_repacked(self, track: AnyTrack) -> None:
|
|
pass
|
|
|
|
def on_track_multiplex(self, track: AnyTrack) -> None:
|
|
pass
|
|
|
|
def close(self) -> None:
|
|
# a long-lived serve opens many of these; the atexit hook would keep every one alive
|
|
atexit.unregister(self.close)
|
|
self._keepalive_stop.set()
|
|
session_id, self._session_id = self._session_id, None
|
|
if session_id:
|
|
try:
|
|
result = self.client.delete(f"/api/session/{session_id}")
|
|
profile = self.ctx.parent.params.get("profile") if self.ctx.parent else None
|
|
self.save_returned_cache(result.get("cache", {}), profile)
|
|
except SystemExit:
|
|
pass
|
|
except Exception as e:
|
|
self.log.warning(f"Failed to clean up remote session: {e}")
|
|
if self.client.tunnel:
|
|
self.client.tunnel.stop()
|
|
|
|
def save_returned_cache(self, cache_data: Dict[str, str], profile: Optional[str] = None) -> None:
|
|
"""Save cache files returned by the server to the local cache directory.
|
|
|
|
The server returns updated cache files (e.g. refreshed tokens) on
|
|
session close. Writing them locally means the next remote session
|
|
can forward them back, skipping interactive auth. The same filter that
|
|
:meth:`load_cache_files` applies drops a file that belongs to another
|
|
credential or profile, so it cannot overwrite that profile's local file.
|
|
The client also refuses a file that names a type other than a datetime.
|
|
"""
|
|
if not cache_data:
|
|
return
|
|
|
|
from unshackle.core.api.compression import safe_inflate
|
|
from unshackle.core.api.sanitize import MAX_SESSION_CACHE_KEYS, safe_cache_key, safe_cache_payload
|
|
|
|
if len(cache_data) > MAX_SESSION_CACHE_KEYS:
|
|
self.log.warning(f"Ignoring {len(cache_data)} cache files from server: more than {MAX_SESSION_CACHE_KEYS}")
|
|
return
|
|
|
|
cache_dir = config.directories.cache / self.service_tag
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
relevant = cache_key_filter(self.service_tag, profile)
|
|
|
|
saved = 0
|
|
for key, content in cache_data.items():
|
|
safe_name = safe_cache_key(key)
|
|
if not safe_name:
|
|
self.log.warning(f"Rejecting unsafe cache filename from server: {key!r}")
|
|
continue
|
|
if not relevant(safe_name):
|
|
self.log.debug(f"Ignoring cache file from the remote server for another profile: {safe_name}")
|
|
continue
|
|
try:
|
|
decompressed = safe_inflate(base64.b64decode(content))
|
|
if not safe_cache_payload(decompressed.decode("utf-8")):
|
|
self.log.warning(
|
|
f"Rejecting cache file '{safe_name}' from the server: it holds a type a peer may not send"
|
|
)
|
|
continue
|
|
# Not with_suffix: that would turn the key "keys.v2" into "keys.json".
|
|
target = cache_dir / f"{safe_name}.json"
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(decompressed)
|
|
saved += 1
|
|
except Exception as e:
|
|
self.log.warning(f"Failed to save returned cache file '{safe_name}': {e}")
|
|
|
|
self.log.info(f"Saved {saved} cache file(s) from server")
|
|
|
|
def load_cache_files(self, profile: Optional[str] = None) -> Dict[str, str]:
|
|
"""Collect the cache files to forward, and withhold other profiles' files.
|
|
|
|
The client cannot rely on the server to filter, so it sends only the files
|
|
it can tie to the active credential or profile (:func:`cache_key_filter`),
|
|
plus service-global state. At worst, a withheld file makes the server
|
|
authenticate again.
|
|
|
|
Each cache key is the file path relative to the service cache directory,
|
|
with posix separators and no ``.json`` suffix, so it equals the ``Cacher``
|
|
cache key the service reads it with (``session_web/<sha1>``).
|
|
"""
|
|
import zlib
|
|
|
|
from unshackle.core.api.sanitize import safe_cache_key
|
|
|
|
cache_dir = config.directories.cache / self.service_tag
|
|
if not cache_dir.is_dir():
|
|
return {}
|
|
|
|
relevant = cache_key_filter(self.service_tag, profile)
|
|
files: Dict[str, str] = {}
|
|
for f in sorted(cache_dir.rglob("*.json")):
|
|
key = f.relative_to(cache_dir).as_posix()[: -len(".json")]
|
|
if f.stem.startswith("titles_") or not safe_cache_key(key):
|
|
continue
|
|
if not relevant(key):
|
|
self.log.debug(f"Withholding cache file from the remote server: {key}")
|
|
continue
|
|
files[key] = base64.b64encode(zlib.compress(f.read_bytes())).decode("ascii")
|
|
return files
|
|
|
|
|
|
__all__ = ("RemoteClient", "RemoteService", "resolve_server")
|