fix(services): match service aliases without case

Uppercase entries in ALIASES never resolved, because get_tag lowercased the input but not the alias. Match real tags first so a service's own tag cannot be taken by another service's alias.
This commit is contained in:
imSp4rky
2026-08-24 11:56:46 -06:00
parent 87cdb30229
commit 0a68cc2452
5 changed files with 58 additions and 20 deletions
+3 -3
View File
@@ -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
+5 -5
View File
@@ -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.
+34
View File
@@ -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"
+5 -5
View File
@@ -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
+11 -7
View File
@@ -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: