diff --git a/docs/dev/creating-a-service.md b/docs/dev/creating-a-service.md index 0f1516f6..c4583673 100644 --- a/docs/dev/creating-a-service.md +++ b/docs/dev/creating-a-service.md @@ -100,6 +100,7 @@ Declared at class level to configure framework behaviour: | `GEOBLOCK` | `tuple[str, ...]` | IP region codes where the service refuses to work. Every other region is allowed. Default `()`. | | `VAULT_TAG` | `Optional[str]` | Overrides the key-vault namespace so sibling services can share one vault. Default `None` (use the service's own tag). | | `AUTH_METHODS` | `Optional[tuple[str, ...]]` | Auth methods accepted (`"cookies"` / `"credentials"`). When `None`, the REST `/services` endpoint infers them from `authenticate()`. | +| `ANONYMOUS_SEARCH` | `bool` | Set `True` when `search()` needs no login and no state from `authenticate()`. A search then skips the cookie load, `authenticate()` and the cookie save. Default `False`. | | `NO_SUBTITLES` | `bool` | Set `True` on a service with no subtitle tracks to skip subtitle handling entirely. | | `ANIME` | `bool` | Set `True` when the catalogue is anime, so metadata lookups prefer AniList. A title's own `anime` flag overrides it. | | `DAILY` | `bool` | Set `True` when the catalogue is daily/date-based (talk shows, news, sports), so unshackle names episodes by air date. A title's own `daily` flag overrides it. | @@ -609,6 +610,12 @@ Override any of these to react to pipeline stages (all no-ops by default): `on_track_repacked`, `on_track_multiplex`. Also override `search()` to yield `SearchResult` objects for `unshackle search`. +!!! warning "Set `ANONYMOUS_SEARCH` only for a search that needs no account" + With `ANONYMOUS_SEARCH = True`, `search()` operates on an HTTP session with no cookies, and + `authenticate()` does not run. Set it only when the search results do not depend on the + account, and when `search()` does its own setup. A `search()` that reads a token, a header + or a region from `authenticate()` fails or gives results for the wrong region. + ```python def search(self) -> Generator[SearchResult, None, None]: results = self.session.get(self.config["endpoints"]["search"], diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 3965e15e..b4e342f3 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -98,6 +98,7 @@ Show the streaming services available on this server, filtered by your allowlist ], "needs_auth": true, "has_search": true, + "anonymous_search": false, "has_drm": true, "auth_methods": ["cookies"] } @@ -120,6 +121,7 @@ Field notes: | `cli_params` | Array of parameter descriptors. Arguments have `{name, kind: "argument", required}`; options have `{name, kind: "option", opts, is_flag, default, help, type, multiple}` and, for choice options, a `choices` list. | | `needs_auth` | Whether the service overrides `authenticate()`. | | `has_search` | Whether the service supports `POST /api/search`. | +| `anonymous_search` | Whether `POST /api/search` operates with no login for this service. The server then ignores `credentials` and `cookies` on a search. | | `has_drm` | Whether the service overrides a Widevine or PlayReady license hook. | | `pending_update` | Set to `true` while a service repository refresh waits for this service's jobs to finish before it swaps in the new code. Absent otherwise. | | `server_accounts` | Present when the server lends its own accounts for this service: `{regions: ["ca", "gb"], global: bool}`, the regions those accounts cover. A client sends no credentials, cookies, or cache for such a service. | @@ -134,6 +136,8 @@ Field notes: Find titles in a service by query string. The service must have a `search()` method (see `has_search` above). +For a service with `anonymous_search`, the server does not log in and ignores `credentials` and `cookies`. The server account check still applies. + **Request body** | Field | Type | Required | Default | Description | diff --git a/docs/dev/rest-api/quickstart.md b/docs/dev/rest-api/quickstart.md index 3cf14057..8e1db805 100644 --- a/docs/dev/rest-api/quickstart.md +++ b/docs/dev/rest-api/quickstart.md @@ -150,6 +150,7 @@ curl "$BASE/api/services" \ "help": "EXAMPLE service documentation...", "needs_auth": true, "has_search": true, + "anonymous_search": false, "has_drm": true, "auth_methods": ["cookies", "credentials"], "cli_params": [] diff --git a/docs/dev/service-architecture.md b/docs/dev/service-architecture.md index 815c3cb9..64f4f256 100644 --- a/docs/dev/service-architecture.md +++ b/docs/dev/service-architecture.md @@ -66,6 +66,7 @@ Declared at the top of your service class, these are static descriptors: | `GEOBLOCK` | `tuple[str, ...]` | ISO region codes where the service refuses to work; every other region is allowed. Use it for a service that works worldwide except in its home market. | | `VAULT_TAG` | `Optional[str]` | Overrides the key-vault namespace; defaults to the service's own tag. | | `AUTH_METHODS` | `Optional[tuple[str, ...]]` | Accepted auth methods (`"cookies"` / `"credentials"`). When `None`, the REST `/services` endpoint infers them from `authenticate()`. | +| `ANONYMOUS_SEARCH` | `bool` | `search()` needs no login and no state from `authenticate()`. A search then skips the cookie load, `authenticate()` and the cookie save. Default `False`. | | `ANIME` | `bool` | The catalogue is anime, so metadata lookups prefer AniList. A title's own `anime` flag overrides it. | | `DAILY` | `bool` | The catalogue is daily/date-based, so unshackle names episodes by air date. A title's own `daily` flag overrides it. | @@ -83,7 +84,8 @@ These are abstract. A service will not load unless it defines all three: `CookieJar` into `self.session.cookies` and stores `self.credential`. Override it to do a real login handshake. - **`search()`**: raises `NotImplementedError` by default. Write it so that the - `search` command can operate. + `search` command can operate. unshackle calls `authenticate()` first, unless the + service sets `ANONYMOUS_SEARCH`. - **DRM license hooks**: `get_widevine_service_certificate`, `get_widevine_license`, `get_playready_license` (delegates to Widevine by default), and `get_clearkey_license` (returns `None` by default). See [DRM hooks](#drm-hooks) below. diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 214f3d39..d65655ff 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -240,6 +240,8 @@ unshackle search [OPTIONS] SERVICE [QUERY...] Each service defines its own query syntax. unshackle prints the results as a tree of titles with their service IDs. Feed an ID straight into `dl`. +unshackle logs in to the service before the search. A service that declares a search with no login is the exception: unshackle then reads no cookies and no credentials, and `--profile` has no effect. + | Option | Description | |---|---| | `-p`, `--profile` | Profile for credentials and cookies. | diff --git a/tests/commands/test_search_anonymous.py b/tests/commands/test_search_anonymous.py new file mode 100644 index 00000000..a1671b79 --- /dev/null +++ b/tests/commands/test_search_anonymous.py @@ -0,0 +1,58 @@ +"""The search command skips the login for a service that sets ANONYMOUS_SEARCH.""" + +from types import SimpleNamespace + +import pytest + +from unshackle.commands import search as search_command +from unshackle.core.search_result import SearchResult + + +class _SearchService: + ANONYMOUS_SEARCH = False + + def __init__(self): + self.calls = [] + self.session = SimpleNamespace(cookies=object()) + + def authenticate(self, cookies, credential): + self.calls.append("authenticate") + + def search(self): + yield SearchResult(id_="abc", title="A Title") + + +@pytest.fixture +def login_calls(monkeypatch, tmp_path): + calls = [] + + def record(name, value): + def call(*args): + calls.append(name) + return value + + return call + + dl = search_command.dl + monkeypatch.setattr(dl, "get_cookie_jar", record("get_cookie_jar", None)) + monkeypatch.setattr(dl, "get_credentials", record("get_credentials", None)) + monkeypatch.setattr(dl, "get_cookie_path", record("get_cookie_path", tmp_path / "cookies.txt")) + monkeypatch.setattr(dl, "save_cookies", record("save_cookies", None)) + return calls + + +def test_search_authenticates_and_saves_cookies_by_default(login_calls): + service = _SearchService() + search_command.result(service) + + assert service.calls == ["authenticate"] + assert login_calls == ["get_cookie_jar", "get_credentials", "get_cookie_path", "save_cookies"] + + +def test_anonymous_search_touches_no_login_material_and_no_cookie_file(login_calls): + service = _SearchService() + service.ANONYMOUS_SEARCH = True + search_command.result(service) + + assert service.calls == [] + assert login_calls == [] diff --git a/tests/remote/unit/test_request_cache.py b/tests/remote/unit/test_request_cache.py index b3cf9424..b4c7f6bc 100644 --- a/tests/remote/unit/test_request_cache.py +++ b/tests/remote/unit/test_request_cache.py @@ -144,6 +144,27 @@ async def test_server_account_uses_its_own_cache_and_returns_none(seen, tmp_path assert not (tmp_path / "_requests").exists() +@pytest.mark.asyncio +async def test_anonymous_search_skips_the_login_and_keeps_the_account_cache(seen, tmp_path, monkeypatch): + def search(self): + seen["search_cache_dir"] = self.cache.get("tokens").path.parent + return iter(()) + + def no_login_material(*args): + raise AssertionError("an anonymous search must not read login material") + + handlers.Services.load("EXAMPLE").ANONYMOUS_SEARCH = True + monkeypatch.setattr(_CacheService, "search", search) + monkeypatch.setattr(handlers, "resolve_server_account", lambda *args: "acct") + monkeypatch.setattr(handlers, "server_login_material", no_login_material) + response = await handlers.search_handler({"service": "EXAMPLE", "query": "q", **CREDENTIALS}) + + assert json.loads(response.body)["results"] == [] + assert "seeded" not in seen + assert seen["parent_params"]["cookies_supplied"] is False + assert seen["search_cache_dir"] == tmp_path / "_accounts" / "EXAMPLE" / "acct" + + @pytest.mark.asyncio async def test_full_mode_keeps_the_server_cache(seen, tmp_path, monkeypatch): monkeypatch.setattr(stats, "mode", "full") diff --git a/unshackle/commands/search.py b/unshackle/commands/search.py index d46dd3e7..7763cdb3 100644 --- a/unshackle/commands/search.py +++ b/unshackle/commands/search.py @@ -96,12 +96,13 @@ def result(service: Service, profile: Optional[str] = None, **_: Any) -> None: service_tag = service.__class__.__name__ - with console.status("Authenticating with Service...", spinner="dots"): - cookies = dl.get_cookie_jar(service_tag, profile) - credential = dl.get_credentials(service_tag, profile) - service.authenticate(cookies, credential) - if cookies or credential: - log.info("Authenticated with Service") + if not service.ANONYMOUS_SEARCH: + with console.status("Authenticating with Service...", spinner="dots"): + cookies = dl.get_cookie_jar(service_tag, profile) + credential = dl.get_credentials(service_tag, profile) + service.authenticate(cookies, credential) + if cookies or credential: + log.info("Authenticated with Service") search_results = Tree("Search Results", hide_root=True) with console.status("Searching...", spinner="dots"): @@ -116,7 +117,7 @@ def result(service: Service, profile: Optional[str] = None, **_: Any) -> None: result_text += f"\n[bright_black]id: {result.id}[/]" search_results.add(result_text + "\n") - cookie_file = dl.get_cookie_path(service_tag, profile) + cookie_file = None if service.ANONYMOUS_SEARCH else dl.get_cookie_path(service_tag, profile) if cookie_file: dl.save_cookies(cookie_file, service.session.cookies) diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index de157835..8dc0d6d8 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -460,6 +460,8 @@ def run_service_search( The same preamble as :func:`setup_list_service`, without the service option extras, plus the search itself. Every step blocks on disk or network, so the caller runs the whole function in one worker thread and keeps the event loop free for other clients. + + The server account check and its cache directory apply to an ``ANONYMOUS_SEARCH`` service too. """ profile = client_profile(data) @@ -473,7 +475,11 @@ def run_service_search( profile = None if account == "default" else account cdm = load_full_cdm(normalized_service, profile, data.get("cdm_type")) - cookies, credential = server_login_material(data, normalized_service, account, profile) + service_module = Services.load(normalized_service) + anonymous = getattr(service_module, "ANONYMOUS_SEARCH", False) + cookies, credential = ( + (None, None) if anonymous else server_login_material(data, normalized_service, account, profile) + ) parent_ctx = build_parent_ctx( profile, @@ -484,7 +490,6 @@ def run_service_search( service_config, extra_params={"cookies_supplied": cookies is not None}, ) - service_module = Services.load(normalized_service) from click import ClickException @@ -505,7 +510,8 @@ def run_service_search( client_login = cookies is not None or credential is not None request_cache.attach(service_instance, data, normalized_service, request, client_login) service_instance._input_bridge = NoUserBridge() - service_instance.authenticate(cookies, credential) + if not anonymous: + service_instance.authenticate(cookies, credential) results: List[Dict[str, Any]] = [] try: diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index c5ab071c..d011b08a 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -388,6 +388,7 @@ async def services(request: web.Request) -> web.Response: if client_config: service_data["client_config"] = list(client_config) service_data["has_search"] = getattr(service_module, "search", None) is not _BaseService.search + service_data["anonymous_search"] = bool(getattr(service_module, "ANONYMOUS_SEARCH", False)) service_data["has_drm"] = ( getattr(service_module, "get_widevine_license", None) is not _BaseService.get_widevine_license or getattr(service_module, "get_playready_license", None) is not _BaseService.get_playready_license @@ -435,7 +436,7 @@ async def search(request: web.Request) -> web.Response: Find titles from a service. --- summary: Find titles - description: Find titles by query string from a service + description: Find titles by query string from a service. A service that reports anonymous_search does the search with no login, and ignores credentials and cookies. requestBody: required: true content: diff --git a/unshackle/core/service.py b/unshackle/core/service.py index f25c64ef..e43e7fd9 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -158,6 +158,7 @@ class Service(metaclass=ABCMeta): GEOBLOCK: tuple[str, ...] = () # ip regions where the service refuses to work; everything else is allowed. ANIME: bool = False # service catalogue is anime; metadata lookups prefer AniList. Title.anime overrides per title. DAILY: bool = False # catalog is daily/date-based. episodes are named by air date. Title.daily overrides per title. + ANONYMOUS_SEARCH: bool = False # search() needs no login; a search then skips cookies and authenticate(). # vault namespace override; when set, key vault read/write uses this tag instead of the service's own. VAULT_TAG: Optional[str] = None # Auth methods the service accepts ("cookies"/"credentials"); when None the REST /services @@ -456,7 +457,8 @@ class Service(metaclass=ABCMeta): This is effectively a login() function. Any API calls or object initializations needing to be made, should be made here. unshackle operates this method before - any of the following abstract functions. + any of the following abstract functions. The one exception is a search on a + Service that sets ``ANONYMOUS_SEARCH``, which does not operate this method. You should avoid storing or using the Credential outside this function. Make any calls you need for any Cookies, Tokens, or such, then use those. diff --git a/unshackle/services/EXAMPLE/__init__.py b/unshackle/services/EXAMPLE/__init__.py index 68a3f543..22f2f63c 100644 --- a/unshackle/services/EXAMPLE/__init__.py +++ b/unshackle/services/EXAMPLE/__init__.py @@ -79,6 +79,9 @@ class EXAMPLE(Service): # ANIME: this catalogue is anime, so metadata lookups ask AniList first. Set it False (the # default) on a mixed catalogue and flag the anime titles individually in get_titles(). ANIME = True + # ANONYMOUS_SEARCH: set True only when search() needs no login and no state from + # authenticate(). A search then skips the cookie load, authenticate() and the cookie save. + ANONYMOUS_SEARCH = False # VAULT_TAG: store/read keys under a different vault namespace than this service's tag. # Lets sibling services share one key vault. Omit to use the service's own tag. VAULT_TAG = "DIFFERENT_NAME"