feat(search): add ANONYMOUS_SEARCH to skip the login for a service

A service that sets the flag gets no cookie load, no authenticate() call and no cookie save on a search, on the CLI and in the REST search handler. /api/services reports it as anonymous_search.
This commit is contained in:
imSp4rky
2026-10-10 13:21:14 -06:00
parent 0cded6427f
commit 0a7c76561c
12 changed files with 121 additions and 13 deletions
+7
View File
@@ -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 `()`. | | `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). | | `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()`. | | `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. | | `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. | | `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. | | `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 `on_track_repacked`, `on_track_multiplex`. Also override `search()` to yield
`SearchResult` objects for `unshackle search`. `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 ```python
def search(self) -> Generator[SearchResult, None, None]: def search(self) -> Generator[SearchResult, None, None]:
results = self.session.get(self.config["endpoints"]["search"], results = self.session.get(self.config["endpoints"]["search"],
+4
View File
@@ -98,6 +98,7 @@ Show the streaming services available on this server, filtered by your allowlist
], ],
"needs_auth": true, "needs_auth": true,
"has_search": true, "has_search": true,
"anonymous_search": false,
"has_drm": true, "has_drm": true,
"auth_methods": ["cookies"] "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. | | `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()`. | | `needs_auth` | Whether the service overrides `authenticate()`. |
| `has_search` | Whether the service supports `POST /api/search`. | | `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. | | `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. | | `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. | | `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). 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** **Request body**
| Field | Type | Required | Default | Description | | Field | Type | Required | Default | Description |
+1
View File
@@ -150,6 +150,7 @@ curl "$BASE/api/services" \
"help": "EXAMPLE service documentation...", "help": "EXAMPLE service documentation...",
"needs_auth": true, "needs_auth": true,
"has_search": true, "has_search": true,
"anonymous_search": false,
"has_drm": true, "has_drm": true,
"auth_methods": ["cookies", "credentials"], "auth_methods": ["cookies", "credentials"],
"cli_params": [] "cli_params": []
+3 -1
View File
@@ -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. | | `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. | | `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()`. | | `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. | | `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. | | `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 `CookieJar` into `self.session.cookies` and stores `self.credential`. Override it to
do a real login handshake. do a real login handshake.
- **`search()`**: raises `NotImplementedError` by default. Write it so that the - **`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`, - **DRM license hooks**: `get_widevine_service_certificate`, `get_widevine_license`,
`get_playready_license` (delegates to Widevine by default), and `get_playready_license` (delegates to Widevine by default), and
`get_clearkey_license` (returns `None` by default). See [DRM hooks](#drm-hooks) below. `get_clearkey_license` (returns `None` by default). See [DRM hooks](#drm-hooks) below.
+2
View File
@@ -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`. 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 | | Option | Description |
|---|---| |---|---|
| `-p`, `--profile` | Profile for credentials and cookies. | | `-p`, `--profile` | Profile for credentials and cookies. |
+58
View File
@@ -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 == []
+21
View File
@@ -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() 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 @pytest.mark.asyncio
async def test_full_mode_keeps_the_server_cache(seen, tmp_path, monkeypatch): async def test_full_mode_keeps_the_server_cache(seen, tmp_path, monkeypatch):
monkeypatch.setattr(stats, "mode", "full") monkeypatch.setattr(stats, "mode", "full")
+8 -7
View File
@@ -96,12 +96,13 @@ def result(service: Service, profile: Optional[str] = None, **_: Any) -> None:
service_tag = service.__class__.__name__ service_tag = service.__class__.__name__
with console.status("Authenticating with Service...", spinner="dots"): if not service.ANONYMOUS_SEARCH:
cookies = dl.get_cookie_jar(service_tag, profile) with console.status("Authenticating with Service...", spinner="dots"):
credential = dl.get_credentials(service_tag, profile) cookies = dl.get_cookie_jar(service_tag, profile)
service.authenticate(cookies, credential) credential = dl.get_credentials(service_tag, profile)
if cookies or credential: service.authenticate(cookies, credential)
log.info("Authenticated with Service") if cookies or credential:
log.info("Authenticated with Service")
search_results = Tree("Search Results", hide_root=True) search_results = Tree("Search Results", hide_root=True)
with console.status("Searching...", spinner="dots"): 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}[/]" result_text += f"\n[bright_black]id: {result.id}[/]"
search_results.add(result_text + "\n") 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: if cookie_file:
dl.save_cookies(cookie_file, service.session.cookies) dl.save_cookies(cookie_file, service.session.cookies)
+9 -3
View File
@@ -460,6 +460,8 @@ def run_service_search(
The same preamble as :func:`setup_list_service`, without the service option extras, 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 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. 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) profile = client_profile(data)
@@ -473,7 +475,11 @@ def run_service_search(
profile = None if account == "default" else account profile = None if account == "default" else account
cdm = load_full_cdm(normalized_service, profile, data.get("cdm_type")) 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( parent_ctx = build_parent_ctx(
profile, profile,
@@ -484,7 +490,6 @@ def run_service_search(
service_config, service_config,
extra_params={"cookies_supplied": cookies is not None}, extra_params={"cookies_supplied": cookies is not None},
) )
service_module = Services.load(normalized_service)
from click import ClickException from click import ClickException
@@ -505,7 +510,8 @@ def run_service_search(
client_login = cookies is not None or credential is not None client_login = cookies is not None or credential is not None
request_cache.attach(service_instance, data, normalized_service, request, client_login) request_cache.attach(service_instance, data, normalized_service, request, client_login)
service_instance._input_bridge = NoUserBridge() service_instance._input_bridge = NoUserBridge()
service_instance.authenticate(cookies, credential) if not anonymous:
service_instance.authenticate(cookies, credential)
results: List[Dict[str, Any]] = [] results: List[Dict[str, Any]] = []
try: try:
+2 -1
View File
@@ -388,6 +388,7 @@ async def services(request: web.Request) -> web.Response:
if client_config: if client_config:
service_data["client_config"] = list(client_config) service_data["client_config"] = list(client_config)
service_data["has_search"] = getattr(service_module, "search", None) is not _BaseService.search 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"] = ( service_data["has_drm"] = (
getattr(service_module, "get_widevine_license", None) is not _BaseService.get_widevine_license 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 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. Find titles from a service.
--- ---
summary: Find titles 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: requestBody:
required: true required: true
content: content:
+3 -1
View File
@@ -158,6 +158,7 @@ class Service(metaclass=ABCMeta):
GEOBLOCK: tuple[str, ...] = () # ip regions where the service refuses to work; everything else is allowed. 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. 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. 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 namespace override; when set, key vault read/write uses this tag instead of the service's own.
VAULT_TAG: Optional[str] = None VAULT_TAG: Optional[str] = None
# Auth methods the service accepts ("cookies"/"credentials"); when None the REST /services # 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 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 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. 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. Make any calls you need for any Cookies, Tokens, or such, then use those.
+3
View File
@@ -79,6 +79,9 @@ class EXAMPLE(Service):
# ANIME: this catalogue is anime, so metadata lookups ask AniList first. Set it False (the # 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(). # default) on a mixed catalogue and flag the anime titles individually in get_titles().
ANIME = True 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. # 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. # Lets sibling services share one key vault. Omit to use the service's own tag.
VAULT_TAG = "DIFFERENT_NAME" VAULT_TAG = "DIFFERENT_NAME"