refactor(api): remove dead code paths in serve/remote layer

Delete code paths that never executed in the serve/API/remote stack:

- BridgeCancelledError: raised but never caught; replace raises with RuntimeError and drop the class.
- InputBridge.error setter and _error field: written at the handler layer, never read (session.auth_error is the surfaced channel).
- cancel_event param and _check_cancel inner fn on _perform_download: the only caller always passed None (the live job.cancel_event is untouched).
- result_with_progress monkeypatch: its status pings were redundant with the file-polled progress_sink, which is now the sole path.
- session param on _resolve_manifest_data: unused in the body.

Reconcile SessionStore's hardcoded 600s auth-state TTL into a shared AUTH_INPUT_TIMEOUT constant referenced by both the InputBridge input timeout and the store, removing the magic number without changing the longer grace window.

Update input_bridge/session_store tests for the new channels and add an assertion that auth-state entries expire at the single rule.
This commit is contained in:
imSp4rky
2026-06-21 13:07:45 -06:00
parent 40eafce17b
commit 8a9a1e1bdc
8 changed files with 36 additions and 65 deletions
+6 -10
View File
@@ -7,7 +7,7 @@ import time
import pytest import pytest
from unshackle.core.api.input_bridge import AuthStatus, BridgeCancelledError, InputBridge from unshackle.core.api.input_bridge import AuthStatus, InputBridge
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@@ -16,7 +16,6 @@ def test_initial_status_is_authenticating() -> None:
bridge = InputBridge() bridge = InputBridge()
assert bridge.status is AuthStatus.AUTHENTICATING assert bridge.status is AuthStatus.AUTHENTICATING
assert bridge.get_pending_prompt() is None assert bridge.get_pending_prompt() is None
assert bridge.error is None
def test_submit_response_returns_false_when_no_prompt_pending() -> None: def test_submit_response_returns_false_when_no_prompt_pending() -> None:
@@ -49,16 +48,15 @@ def test_request_input_blocks_until_submit() -> None:
def test_request_input_times_out() -> None: def test_request_input_times_out() -> None:
bridge = InputBridge() bridge = InputBridge()
with pytest.raises(TimeoutError): with pytest.raises(TimeoutError, match="No client response"):
bridge.request_input("hello", timeout=0.6) bridge.request_input("hello", timeout=0.6)
assert bridge.status is AuthStatus.FAILED assert bridge.status is AuthStatus.FAILED
assert "timed out" in (bridge.error or "")
def test_cancel_before_request_raises_immediately() -> None: def test_cancel_before_request_raises_immediately() -> None:
bridge = InputBridge() bridge = InputBridge()
bridge.cancel() bridge.cancel()
with pytest.raises(BridgeCancelledError): with pytest.raises(RuntimeError, match="cancelled"):
bridge.request_input("hello", timeout=5) bridge.request_input("hello", timeout=5)
assert bridge.status is AuthStatus.FAILED assert bridge.status is AuthStatus.FAILED
@@ -70,7 +68,7 @@ def test_cancel_unblocks_pending_request() -> None:
def worker() -> None: def worker() -> None:
try: try:
bridge.request_input("OTP?", timeout=5) bridge.request_input("OTP?", timeout=5)
except BridgeCancelledError as e: except RuntimeError as e:
exc.append(e) exc.append(e)
t = threading.Thread(target=worker) t = threading.Thread(target=worker)
@@ -82,7 +80,7 @@ def test_cancel_unblocks_pending_request() -> None:
bridge.cancel() bridge.cancel()
t.join(timeout=2) t.join(timeout=2)
assert exc and isinstance(exc[0], BridgeCancelledError) assert exc and isinstance(exc[0], RuntimeError)
assert bridge.status is AuthStatus.FAILED assert bridge.status is AuthStatus.FAILED
@@ -92,9 +90,7 @@ def test_get_pending_prompt_returns_none_outside_pending_state() -> None:
assert bridge.get_pending_prompt() is None assert bridge.get_pending_prompt() is None
def test_status_and_error_setters() -> None: def test_status_setter() -> None:
bridge = InputBridge() bridge = InputBridge()
bridge.status = AuthStatus.AUTHENTICATED bridge.status = AuthStatus.AUTHENTICATED
bridge.error = "boom"
assert bridge.status is AuthStatus.AUTHENTICATED assert bridge.status is AuthStatus.AUTHENTICATED
assert bridge.error == "boom"
+15
View File
@@ -85,6 +85,21 @@ async def test_cleanup_expired_keeps_pending_input_under_grace(store: SessionSto
assert store.session_count == 1 assert store.session_count == 1
async def test_cleanup_expired_drops_pending_input_past_grace(store: SessionStore) -> None:
"""The auth-state grace is the single AUTH_INPUT_TIMEOUT rule, not a separate magic number."""
from datetime import datetime, timedelta, timezone
from unshackle.core.api.input_bridge import AUTH_INPUT_TIMEOUT
entry = await store.create("ATV", _FakeService())
entry.input_bridge = InputBridge()
entry.auth_status = AuthStatus.PENDING_INPUT
entry.last_accessed = datetime.now(timezone.utc) - timedelta(seconds=AUTH_INPUT_TIMEOUT + 1)
removed = await store.cleanup_expired()
assert removed == 1
assert store.session_count == 0
async def test_cancel_all_bridges(store: SessionStore) -> None: async def test_cancel_all_bridges(store: SessionStore) -> None:
a = await store.create("ATV", _FakeService()) a = await store.create("ATV", _FakeService())
b = await store.create("NF", _FakeService()) b = await store.create("NF", _FakeService())
+2 -29
View File
@@ -160,20 +160,13 @@ def _perform_download(
service: str, service: str,
title_id: str, title_id: str,
params: Dict[str, Any], params: Dict[str, Any],
cancel_event: Optional[threading.Event] = None,
progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None, progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
) -> List[str]: ) -> List[str]:
"""Execute the synchronous download logic for a job.""" """Execute the synchronous download logic for a job."""
def _check_cancel(stage: str):
if cancel_event and cancel_event.is_set():
raise Exception(f"Job was cancelled {stage}")
from contextlib import redirect_stderr, redirect_stdout from contextlib import redirect_stderr, redirect_stdout
from io import StringIO from io import StringIO
_check_cancel("before execution started")
# Import dl.py components lazily to avoid circular deps during module import # Import dl.py components lazily to avoid circular deps during module import
import click import click
import yaml import yaml
@@ -328,8 +321,6 @@ def _perform_download(
service_module = Services.load(service) service_module = Services.load(service)
_check_cancel("before service instantiation")
try: try:
import inspect import inspect
@@ -367,29 +358,11 @@ def _perform_download(
original_download_dir = config.directories.downloads original_download_dir = config.directories.downloads
_check_cancel("before download execution")
stdout_capture = StringIO() stdout_capture = StringIO()
stderr_capture = StringIO() stderr_capture = StringIO()
# The progress_sink (dl.build_job_progress_callables) owns the percentage; status changes # progress_sink (dl.build_job_progress_callables) owns percentage; terminal status
# are emitted here. # (success/failure) is reported by the worker's result file.
if progress_callback:
progress_callback({"progress": 0.0, "status": "starting"})
original_result = dl_instance.result
def result_with_progress(*args, **kwargs):
try:
progress_callback({"status": "downloading"})
result = original_result(*args, **kwargs)
progress_callback({"progress": 100.0, "status": "completed"})
return result
except Exception as e:
progress_callback({"progress": 0.0, "status": "failed", "error": str(e)})
raise
dl_instance.result = result_with_progress
try: try:
with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
dl_instance.result( dl_instance.result(
+1 -1
View File
@@ -60,7 +60,7 @@ def main(argv: list[str]) -> int:
log.error(f"Failed to write progress update: {e}") log.error(f"Failed to write progress update: {e}")
output_files = _perform_download( output_files = _perform_download(
job_id, service, title_id, params, cancel_event=None, progress_callback=progress_callback job_id, service, title_id, params, progress_callback=progress_callback
) )
result = {"status": "success", "output_files": output_files} result = {"status": "success", "output_files": output_files}
-1
View File
@@ -1442,7 +1442,6 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
session.auth_status = AuthStatus.FAILED session.auth_status = AuthStatus.FAILED
session.auth_error = str(e) session.auth_error = str(e)
bridge.status = AuthStatus.FAILED bridge.status = AuthStatus.FAILED
bridge.error = str(e)
asyncio.create_task(_run_auth()) asyncio.create_task(_run_auth())
+8 -20
View File
@@ -25,8 +25,9 @@ class AuthStatus(Enum):
FAILED = "failed" FAILED = "failed"
class BridgeCancelledError(Exception): # Seconds the server waits for a remote client to answer an input prompt before
"""Raised when the bridge is cancelled (client disconnected, session deleted).""" # giving up; also the TTL SessionStore grants AUTHENTICATING/PENDING_INPUT sessions.
AUTH_INPUT_TIMEOUT = 600.0
@dataclass @dataclass
@@ -40,12 +41,11 @@ class InputBridge:
_prompt: Optional[str] = field(default=None, init=False, repr=False) _prompt: Optional[str] = field(default=None, init=False, repr=False)
_response: Optional[str] = field(default=None, init=False, repr=False) _response: Optional[str] = field(default=None, init=False, repr=False)
_status: AuthStatus = field(default=AuthStatus.AUTHENTICATING, init=False) _status: AuthStatus = field(default=AuthStatus.AUTHENTICATING, init=False)
_error: Optional[str] = field(default=None, init=False, repr=False)
_cancelled: bool = field(default=False, init=False, repr=False) _cancelled: bool = field(default=False, init=False, repr=False)
_response_ready: threading.Event = field(default_factory=threading.Event, init=False, repr=False) _response_ready: threading.Event = field(default_factory=threading.Event, init=False, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
def request_input(self, prompt: str, timeout: float = 600.0) -> str: def request_input(self, prompt: str, timeout: float = AUTH_INPUT_TIMEOUT) -> str:
"""Block until the remote client submits a response for *prompt*. """Block until the remote client submits a response for *prompt*.
Args: Args:
@@ -57,11 +57,11 @@ class InputBridge:
Raises: Raises:
TimeoutError: If no response is received within *timeout*. TimeoutError: If no response is received within *timeout*.
BridgeCancelledError: If the bridge was cancelled. RuntimeError: If the bridge was cancelled.
""" """
with self._lock: with self._lock:
if self._cancelled: if self._cancelled:
raise BridgeCancelledError("Session was cancelled") raise RuntimeError("Session was cancelled")
self._prompt = prompt self._prompt = prompt
self._response = None self._response = None
self._status = AuthStatus.PENDING_INPUT self._status = AuthStatus.PENDING_INPUT
@@ -73,16 +73,15 @@ class InputBridge:
break break
with self._lock: with self._lock:
if self._cancelled: if self._cancelled:
raise BridgeCancelledError("Session was cancelled") raise RuntimeError("Session was cancelled")
else: else:
with self._lock: with self._lock:
self._status = AuthStatus.FAILED self._status = AuthStatus.FAILED
self._error = "Input request timed out waiting for client response"
raise TimeoutError(f"No client response for prompt within {timeout}s") raise TimeoutError(f"No client response for prompt within {timeout}s")
with self._lock: with self._lock:
if self._cancelled: if self._cancelled:
raise BridgeCancelledError("Session was cancelled") raise RuntimeError("Session was cancelled")
response = self._response or "" response = self._response or ""
self._prompt = None self._prompt = None
self._response = None self._response = None
@@ -115,7 +114,6 @@ class InputBridge:
with self._lock: with self._lock:
self._cancelled = True self._cancelled = True
self._status = AuthStatus.FAILED self._status = AuthStatus.FAILED
self._error = "Session cancelled"
self._response_ready.set() self._response_ready.set()
@property @property
@@ -127,13 +125,3 @@ class InputBridge:
def status(self, value: AuthStatus) -> None: def status(self, value: AuthStatus) -> None:
with self._lock: with self._lock:
self._status = value self._status = value
@property
def error(self) -> Optional[str]:
with self._lock:
return self._error
@error.setter
def error(self, value: Optional[str]) -> None:
with self._lock:
self._error = value
+2 -2
View File
@@ -14,7 +14,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from unshackle.core.api.input_bridge import AuthStatus, InputBridge from unshackle.core.api.input_bridge import AUTH_INPUT_TIMEOUT, AuthStatus, InputBridge
from unshackle.core.api.sanitize import sanitize_log from unshackle.core.api.sanitize import sanitize_log
from unshackle.core.config import config from unshackle.core.config import config
from unshackle.core.tracks import Track from unshackle.core.tracks import Track
@@ -127,7 +127,7 @@ class SessionStore:
for sid, entry in self._sessions.items(): for sid, entry in self._sessions.items():
elapsed = (now - entry.last_accessed).total_seconds() elapsed = (now - entry.last_accessed).total_seconds()
if entry.auth_status in (AuthStatus.AUTHENTICATING, AuthStatus.PENDING_INPUT): if entry.auth_status in (AuthStatus.AUTHENTICATING, AuthStatus.PENDING_INPUT):
if elapsed > 600: if elapsed > AUTH_INPUT_TIMEOUT:
expired.append(sid) expired.append(sid)
elif elapsed > self._ttl: elif elapsed > self._ttl:
expired.append(sid) expired.append(sid)
+2 -2
View File
@@ -198,7 +198,7 @@ def _build_tracks(data: Dict[str, Any]) -> Tracks:
return tracks return tracks
def _resolve_manifest_data(tracks: Tracks, manifests: list, session: Any) -> None: def _resolve_manifest_data(tracks: Tracks, manifests: list) -> None:
"""Re-parse serialized manifests and populate track.data for downloading. """Re-parse serialized manifests and populate track.data for downloading.
The server serializes DASH and ISM manifest XML as zlib-compressed base64. The server serializes DASH and ISM manifest XML as zlib-compressed base64.
@@ -604,7 +604,7 @@ class RemoteService:
for k, v in result.get("session_cookies", {}).items(): for k, v in result.get("session_cookies", {}).items():
self._session.cookies.set(k, v) self._session.cookies.set(k, v)
_resolve_manifest_data(tracks, result.get("manifests", []), self._session) _resolve_manifest_data(tracks, result.get("manifests", []))
self._server_cdm_type = result.get("server_cdm_type", "widevine") self._server_cdm_type = result.get("server_cdm_type", "widevine")