feat(serve): relay approval messages to jobs and proxy remote downloads

A REST job shows a wait_for_approval message as its notice field. search, list-titles and list-tracks fail at once on a login that needs the user. A remote client downloads through the --proxy it sends.
This commit is contained in:
imSp4rky
2026-10-07 11:15:16 -06:00
parent 5b0369e63a
commit 09cae2ec3f
16 changed files with 233 additions and 28 deletions
+45 -1
View File
@@ -1,9 +1,11 @@
import logging
from contextlib import nullcontext
from types import SimpleNamespace
import pytest
from unshackle.core.api.input_bridge import InputBridge
from unshackle.core import console
from unshackle.core.api.input_bridge import NO_USER_MESSAGE, InputBridge, NoUserBridge
from unshackle.core.service import Service
@@ -32,3 +34,45 @@ def test_stops_when_the_remote_session_is_cancelled():
with pytest.raises(RuntimeError):
Service.wait_for_approval(_service(bridge), "enter code", check, timeout=5, interval=0)
assert len(calls) == 1
def _raise_value_error():
raise ValueError("denied")
def test_logs_the_message_for_a_remote_session(caplog):
"""A remote session reads the message only from the service log."""
with caplog.at_level(logging.INFO, logger="test"):
Service.wait_for_approval(_service(), "enter code", lambda: "token", timeout=5, interval=0)
assert "enter code" in caplog.messages
def test_a_request_with_no_user_fails_at_once():
calls = []
with pytest.raises(RuntimeError, match="needs the user"):
Service.wait_for_approval(_service(NoUserBridge()), "enter code", lambda: calls.append(1), timeout=5)
assert not calls
with pytest.raises(RuntimeError, match="needs the user"):
Service.request_input(_service(NoUserBridge()), "Enter OTP")
def test_no_user_message_is_an_authentication_error():
from unshackle.core.api.errors import APIErrorCode, categorize_exception
assert categorize_exception(RuntimeError(NO_USER_MESSAGE)).error_code == APIErrorCode.AUTH_FAILED
@pytest.mark.parametrize(
"check, expectation",
[
(lambda: "token", nullcontext()),
(lambda: None, pytest.raises(TimeoutError)),
(_raise_value_error, pytest.raises(ValueError)),
],
)
def test_shows_the_message_as_a_notice_and_clears_it(monkeypatch, check, expectation):
notices = []
monkeypatch.setattr(console, "_notice_handler", notices.append)
with expectation:
Service.wait_for_approval(_service(), "enter code", check, timeout=0.05, interval=0.01)
assert notices == ["enter code", None]
@@ -286,6 +286,30 @@ def test_worker_relays_a_prompt_and_reads_the_answer(monkeypatch: pytest.MonkeyP
assert updates == [{"input_prompt": "Enter PIN"}, {"input_prompt": None}]
def test_worker_relays_a_notice_as_progress(monkeypatch: pytest.MonkeyPatch) -> None:
"""A show_notice() message reaches the parent as ``notice`` progress, and None clears it."""
import os
import sys
from types import SimpleNamespace
from unshackle.core.api import download_worker
from unshackle.core.console import set_notice_handler, set_prompt_handler, show_notice
read_fd, write_fd = os.pipe()
monkeypatch.setattr(sys, "stdin", SimpleNamespace(fileno=lambda: read_fd))
updates: list[dict] = []
download_worker.relay_prompts(updates.append)
try:
show_notice("Enter pair code ABCD")
show_notice(None)
finally:
set_prompt_handler(None)
set_notice_handler(None)
os.close(write_fd)
assert updates == [{"notice": "Enter pair code ABCD"}, {"notice": None}]
def test_a_prompt_discards_an_answer_sent_before_it(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A late answer to an earlier prompt must not answer the next one; the timeout is not retryable."""
import sys
@@ -420,6 +444,46 @@ async def test_a_repeated_prompt_reaches_an_event_listener(
assert job.input_prompt is None
async def test_a_notice_reaches_the_job_and_an_event_listener(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, manager: DownloadQueueManager
) -> None:
"""The job carries the worker's notice while the worker waits, and drops it at the end."""
import asyncio
from unshackle.core.api import download_manager
worker = tmp_path / "worker.py"
# The prompt holds the worker open until the test has seen the notice.
source = FAKE_PROMPT_WORKER.replace("import prompt_user", "import prompt_user, show_notice").replace(
'answers = [prompt_user("Enter OTP"), prompt_user("Enter OTP")]',
'show_notice("Enter pair code ABCD")\nanswers = [prompt_user("Continue")]',
)
assert source.count("show_notice") == 2, "FAKE_PROMPT_WORKER changed; update the replacements"
worker.write_text(source, encoding="utf-8")
spawn = asyncio.create_subprocess_exec
async def spawn_fake_worker(executable: str, *args: str, **kwargs: Any) -> Any:
return await spawn(executable, str(worker), *args[2:], **kwargs)
monkeypatch.setattr(download_manager.asyncio, "create_subprocess_exec", spawn_fake_worker)
job = manager.create_job("EXAMPLE", "t")
events = manager.subscribe(job.job_id)
run = asyncio.create_task(manager.run_download_async(job))
async def answer_after_notice() -> None:
while True:
event = await events.get()
if event and event["data"]["input_prompt"]:
assert event["data"]["notice"] == "Enter pair code ABCD"
assert job.to_dict()["notice"] == "Enter pair code ABCD"
assert manager.submit_input(job, "ok") is True
return
await asyncio.wait_for(answer_after_notice(), timeout=20)
assert await asyncio.wait_for(run, timeout=20) == ["ok"]
assert job.notice is None
async def test_input_handler_rejects_bad_requests(
monkeypatch: pytest.MonkeyPatch, manager: DownloadQueueManager
) -> None:
@@ -7,6 +7,7 @@ from enum import Enum
import pytest
from unshackle.core.remote_service import (
RemoteService,
build_title,
build_tracks,
deserialize_audio,
@@ -563,11 +564,12 @@ class _SentCreate(Exception):
pass
def _sent_create_data(parent_params: dict, service_params: dict) -> dict:
def _remote_service(parent_params: dict, service_params: dict) -> RemoteService:
"""A RemoteService whose session create raises _SentCreate with the request body."""
import logging
from types import SimpleNamespace
from unshackle.core.remote_service import RemoteService
import requests
def post(path: str, data: dict) -> dict:
raise _SentCreate(data)
@@ -579,11 +581,32 @@ def _sent_create_data(parent_params: dict, service_params: dict) -> dict:
svc._server_accounts = {"global": True}
svc._server_cdm = True
svc._service_params = service_params
svc._session = requests.Session()
svc.log = logging.getLogger("test")
svc.client = SimpleNamespace(post=post)
svc.client = SimpleNamespace(post=post, server_url="https://server.example")
return svc
def _sent_create_data(parent_params: dict, service_params: dict) -> dict:
with pytest.raises(_SentCreate) as exc:
_remote_service(parent_params, service_params).authenticate()
return exc.value.args[0]
@pytest.mark.parametrize("no_proxy, expected", [(False, {"all": "http://proxy.example:8080"}), (True, {})])
def test_the_client_session_downloads_through_the_proxy_it_sends(
monkeypatch: pytest.MonkeyPatch, no_proxy: bool, expected: dict
) -> None:
"""The client fetches playlists and segments itself, so --proxy must reach its own session too."""
from unshackle.core import remote_service
monkeypatch.setattr(remote_service, "resolve_remote_proxy_arg", lambda proxy: proxy)
monkeypatch.setattr("unshackle.core.utils.ip_info.get_ip_info", lambda *args, **kwargs: None)
svc = _remote_service({"no_proxy": no_proxy, "proxy": "http://proxy.example:8080"}, {})
with pytest.raises(_SentCreate) as exc:
svc.authenticate()
return exc.value.args[0]
assert dict(svc._session.proxies) == expected
assert exc.value.args[0].get("proxy") == (None if no_proxy else "http://proxy.example:8080")
def test_dl_selection_travels_nested_and_a_service_option_cannot_override_it() -> None: