mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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"],
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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": []
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user