diff --git a/docs/dev/creating-a-service.md b/docs/dev/creating-a-service.md index 9c8e1ceb..c5da92a4 100644 --- a/docs/dev/creating-a-service.md +++ b/docs/dev/creating-a-service.md @@ -95,13 +95,13 @@ Declared at class level to configure framework behaviour: | Variable | Type | Purpose | |---|---|---| -| `ALIASES` | `tuple[str, ...]` | Extra tags that resolve to this service (e.g. `("EX", "DOMAIN")`). Default `()`. | -| `GEOFENCE` | `tuple[str, ...]` | IP region codes the service requires. Empty = no geofence. The **first** entry is treated as the main region for auto-proxy. | +| `ALIASES` | `tuple[str, ...]` | Extra tags that resolve to this service (e.g. `("EX", "DOMAIN")`). Case does not matter. Default `()`. | +| `GEOFENCE` | `tuple[str, ...]` | IP region codes the service requires. Empty = no geofence. unshackle treats the **first** entry as the main region for auto-proxy. | | `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()`. | | `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 episodes are named 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. | !!! note "`NO_SUBTITLES` is a convention, not a base-class attribute" `dl.py` uses `hasattr` to find `NO_SUBTITLES`, and the `Service` base class diff --git a/docs/dev/service-architecture.md b/docs/dev/service-architecture.md index c86ac9d4..eacbdb3b 100644 --- a/docs/dev/service-architecture.md +++ b/docs/dev/service-architecture.md @@ -61,12 +61,12 @@ Declared at the top of your service class, these are static descriptors: | Class variable | Type | Purpose | |---|---|---| -| `ALIASES` | `tuple[str, ...]` | Alternate tags the service answers to (resolved by `Services.get_tag`). | -| `GEOFENCE` | `tuple[str, ...]` | ISO region codes the service requires; empty means no geofence. The first entry is treated as the "main region" for automatic proxy selection. | +| `ALIASES` | `tuple[str, ...]` | Alternate tags the service answers to, in any case (resolved by `Services.get_tag`). | +| `GEOFENCE` | `tuple[str, ...]` | ISO region codes the service requires; empty means no geofence. unshackle treats the first entry as the "main region" for automatic proxy selection. | | `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()`. | | `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 episodes are named 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. | ### Required methods @@ -141,7 +141,7 @@ families, one per media kind: All items subclass `Title`, which carries the shared identity: a **unique `id`**, the originating `service` class, an optional original `language`, arbitrary `data`, and a freshly-constructed `self.tracks = Tracks()` that `get_tracks` will fill. Collections -are sorted lists, so titles stay in a stable order. +use sorted lists, so titles stay in a stable order. !!! warning "The unique ID is validated" `Title.__init__` rejects a falsy `id_`, and rejects any ID shorter than 4 characters @@ -330,7 +330,7 @@ in `unshackle.core.manifests`. Each parser has the same three-part contract: ### The parser contract 1. **Construct**: `DASH.from_url(url, session=...)` or `DASH.from_text(text, url)` - (and likewise for `HLS` / `ISM`). A `url` is always required, even for `from_text`, + (and likewise for `HLS` / `ISM`). Always pass a `url`, even to `from_text`, because the parser needs it to make relative segment paths absolute. 2. **`to_tracks(...)`**: parses the manifest into a `Tracks` container of `Video`, `Audio`, and `Subtitle` objects, each carrying descriptor-specific state. diff --git a/tests/core/test_service_alias_case.py b/tests/core/test_service_alias_case.py new file mode 100644 index 00000000..1bc31727 --- /dev/null +++ b/tests/core/test_service_alias_case.py @@ -0,0 +1,34 @@ +"""Service tags resolve from an alias in any case, and a real tag beats another service's alias.""" + +from pathlib import Path + +import pytest + + +@pytest.fixture +def two_services(monkeypatch: pytest.MonkeyPatch) -> None: + """SVCA claims "SVCB" as an alias, but SVCB is a service in its own right.""" + from unshackle.core import services + + monkeypatch.setattr(services, "SERVICES", [Path("SVCA") / "__init__.py", Path("SVCB") / "__init__.py"]) + monkeypatch.setattr(services, "ALIASES", {"SVCA": ("SVCX", "SVCB"), "SVCB": ()}) + + +@pytest.mark.parametrize("value", ["SVCX", "svcx", "SvCx"]) +def test_alias_resolves_in_any_case(two_services: None, value: str) -> None: + from unshackle.core.services import Services + + assert Services.get_tag(value) == "SVCA" + + +@pytest.mark.parametrize("value", ["SVCB", "svcb", "SvCb"]) +def test_real_tag_beats_another_services_alias(two_services: None, value: str) -> None: + from unshackle.core.services import Services + + assert Services.get_tag(value) == "SVCB" + + +def test_unknown_value_is_returned_unchanged(two_services: None) -> None: + from unshackle.core.services import Services + + assert Services.get_tag("NOPE") == "NOPE" diff --git a/unshackle/core/service.py b/unshackle/core/service.py index 5ec0b36e..28a5077b 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -171,7 +171,7 @@ class Service(metaclass=ABCMeta): still, during track download. """ - ALIASES: tuple[str, ...] = () # list of aliases for the service; alternatives to the service tag. + ALIASES: tuple[str, ...] = () # alternative tags for the service, matched without case. GEOFENCE: tuple[str, ...] = () # list of ip regions required to use the service. empty list == no specific region. 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. @@ -489,8 +489,8 @@ class Service(metaclass=ABCMeta): read by the Cdm and decrypted. This is a very important request to get correct. A bad, unexpected, or missing - value in the request can cause the service to detect your CDM device. The device - can then be banned, revoked, disabled, or downgraded. + value in the request can cause the service to detect your CDM device. + The service can then ban, revoke, disable, or downgrade that device. :param challenge: The license challenge from the Widevine CDM. :param title: The current `Title` from get_titles that unshackle processes now. unshackle @@ -510,8 +510,8 @@ class Service(metaclass=ABCMeta): read by the CDM and decrypted. This is a very important request to get correct. A bad, unexpected, or missing - value in the request can cause the service to detect your CDM device. The device - can then be banned, revoked, disabled, or downgraded. + value in the request can cause the service to detect your CDM device. + The service can then ban, revoke, disable, or downgrade that device. :param challenge: The license challenge from the PlayReady CDM. :param title: The current `Title` from get_titles that unshackle processes now. unshackle diff --git a/unshackle/core/services.py b/unshackle/core/services.py index 2f1e9c3d..284103c0 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -317,18 +317,22 @@ class Services(click.Group): def get_tag(value: str) -> str: """ Get the Service Tag (the name of the service's directory, not one of its aliases) by an Alias. - Input value can be of any case-sensitivity. + Input value can be of any case-sensitivity, and so can the alias it matches. + A real tag always wins over an alias: services do declare aliases that collide with another service's tag. This method returns the original input value if it does not match a service tag. """ - original_value = value - value = value.lower() + value_lower = value.lower() + tags = [path.parent.stem for path in SERVICES] - for path in SERVICES: - tag = path.parent.stem - if value in (tag.lower(), *ALIASES.get(tag, [])): + for tag in tags: + if value_lower == tag.lower(): return tag - return original_value + for tag in tags: + if any(value_lower == alias.lower() for alias in ALIASES.get(tag, ())): + return tag + + return value @staticmethod def load(tag: str) -> Service: