From 1cb0e4b7665f8e5826b581c0f74e9218920c3528 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 26 May 2026 14:08:52 -0600 Subject: [PATCH] feat(naming): per-service title_map remapping (#106) Add services..title_map exact-match dict to rewrite service-provided titles before naming/output. Shared remap_titles helper applied on local dl, import, and client-side dl --remote (server stays raw so clients can override names for services they don't have installed locally). --- docs/SERVICE_CONFIG.md | 25 +++++++++++++++++++- unshackle/core/import_service.py | 6 +++-- unshackle/core/remote_service.py | 10 ++++++-- unshackle/core/service.py | 17 +++++++++++--- unshackle/core/titles/__init__.py | 38 ++++++++++++++++++++++++++++++- unshackle/unshackle-example.yaml | 5 ++++ 6 files changed, 92 insertions(+), 9 deletions(-) diff --git a/docs/SERVICE_CONFIG.md b/docs/SERVICE_CONFIG.md index 6fb37d3..37d80bf 100644 --- a/docs/SERVICE_CONFIG.md +++ b/docs/SERVICE_CONFIG.md @@ -27,7 +27,7 @@ EXAMPLE: You can override many global configuration options on a per-service basis by nesting them under the service tag in the `services` section. Supported override keys include: `dl`, `subtitle`, `muxing`, -`headers`, `proxy_map`, and more. +`headers`, `proxy_map`, `title_map`, and more. Overrides are merged with global config (not replaced) -- only specified keys are overridden, others use global defaults. CLI arguments always take priority over service-specific config. @@ -47,6 +47,29 @@ services: Note: unshackle uses a single unified `requests`-based downloader. The legacy `aria2c`, `n_m3u8dl_re`, and `curl_impersonate` override sections have been removed. +### title_map (dict) + +Rewrites service-provided titles before naming and output. Some services name a title differently +from how you want it stored, which can break library matching (e.g. a regional variant reusing the +international name). Keys are the exact title string the service returns; values are the desired +output title. + +```yaml +services: + EXAMPLE: + title_map: + Service Title: Desired Title +``` + +Episodes are matched on their show title, Movies and Songs on their name. The remap is applied +after the title cache (so edits take effect without a cache reset) and before any `--enrich` +override (so an explicit enrich still wins). + +It applies on the local `dl` path, the `import` command, and the remote client (`dl --remote`). +For remote services the **client's** `title_map` is applied to the titles returned by the server, +so you can rename titles for services you don't have installed locally. The server sends raw +titles and does not remap, leaving the final name fully under the client's control. + ### Service Class Conventions Each service directory under `unshackle/services/` exports a class extending diff --git a/unshackle/core/import_service.py b/unshackle/core/import_service.py index d62ff05..3d1e44e 100644 --- a/unshackle/core/import_service.py +++ b/unshackle/core/import_service.py @@ -16,7 +16,7 @@ from unshackle.core.credential import Credential from unshackle.core.drm import drm_from_dict from unshackle.core.manifests import DASH, HLS, ISM from unshackle.core.remote_service import RemoteService, _build_title, _resolve_proxy -from unshackle.core.titles import Episode, Movies, Series, Title_T, Titles_T +from unshackle.core.titles import Episode, Movies, Series, Title_T, Titles_T, remap_titles from unshackle.core.tracks import Audio, Chapter, Chapters, Tracks, Video from unshackle.core.tracks.track import Track @@ -123,7 +123,9 @@ class ImportService: return self.titles def get_titles_cached(self, title_id: Optional[str] = None) -> Titles_T: - return self.get_titles() + """Apply the service's title_map to titles reconstructed from the export sidecar.""" + title_map = (config.services.get(self.service_tag) or {}).get("title_map") or {} + return remap_titles(self.get_titles(), title_map) def get_tracks(self, title: Title_T) -> Tracks: """Reconstruct the title's tracks from the export. diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 7a8d6c7..b725f65 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -25,7 +25,7 @@ from unshackle.core.config import config from unshackle.core.console import console from unshackle.core.constants import AnyTrack from unshackle.core.credential import Credential -from unshackle.core.titles import Title_T, Titles_T +from unshackle.core.titles import Title_T, Titles_T, remap_titles from unshackle.core.titles.episode import Episode, Series from unshackle.core.titles.movie import Movie, Movies from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video @@ -573,7 +573,13 @@ class RemoteService: return self._titles def get_titles_cached(self, title_id: str = None) -> Titles_T: - return self.get_titles() + """Apply the client's local title_map to titles fetched from the remote server. + + Lets users rename titles for remote services they don't have installed locally. + The server sends raw titles; the client's own ``services..title_map`` wins. + """ + title_map = (config.services.get(self.service_tag) or {}).get("title_map") or {} + return remap_titles(self.get_titles(), title_map) def get_tracks(self, title: Title_T) -> Tracks: title_id = str(title.id) diff --git a/unshackle/core/service.py b/unshackle/core/service.py index c49ac89..edc780a 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -27,7 +27,7 @@ from unshackle.core.credential import Credential from unshackle.core.drm import DRM_T from unshackle.core.search_result import SearchResult from unshackle.core.title_cacher import TitleCacher, get_account_hash, get_region_from_proxy -from unshackle.core.titles import Title_T, Titles_T +from unshackle.core.titles import Title_T, Titles_T, remap_titles from unshackle.core.tracks import Chapters, Tracks from unshackle.core.tracks.video import Video from unshackle.core.utils.ip_info import get_ip_info @@ -482,7 +482,7 @@ class Service(metaclass=ABCMeta): else: # If we can't determine title_id, just call get_titles directly self.log.debug("Cannot determine title_id for caching, bypassing cache") - return self.get_titles() + return self.apply_title_map(self.get_titles()) # Get cache control flags from context no_cache = False @@ -495,7 +495,7 @@ class Service(metaclass=ABCMeta): account_hash = get_account_hash(self.credential) # Use title cache to get titles with fallback support - return self.title_cache.get_cached_titles( + titles = self.title_cache.get_cached_titles( title_id=str(title_id), fetch_function=self.get_titles, region=self.current_region, @@ -503,6 +503,17 @@ class Service(metaclass=ABCMeta): no_cache=no_cache, reset_cache=reset_cache, ) + return self.apply_title_map(titles) + + def apply_title_map(self, titles: Titles_T) -> Titles_T: + """ + Rewrite service-provided titles using the per-service ``title_map`` config. + + ``title_map`` lives under ``services.`` in unshackle.yaml. Applied after the + title cache so config edits take effect without a cache reset, and before any + ``--enrich`` override so enrich wins. See ``remap_titles`` for the match rules. + """ + return remap_titles(titles, (self.config or {}).get("title_map") or {}) @abstractmethod def get_tracks(self, title: Title_T) -> Tracks: diff --git a/unshackle/core/titles/__init__.py b/unshackle/core/titles/__init__.py index 304a8f1..9c95645 100644 --- a/unshackle/core/titles/__init__.py +++ b/unshackle/core/titles/__init__.py @@ -8,4 +8,40 @@ Title_T = Union[Movie, Episode, Song] Titles_T = Union[Movies, Series, Album] -__all__ = ("Episode", "Series", "Movie", "Movies", "Album", "Song", "Title_T", "Titles_T") +def remap_titles(titles: Titles_T, title_map: dict) -> Titles_T: + """ + Rewrite titles in-place using an exact-match ``title_map``. + + Some services name a title differently from how the user wants it stored, which can + break library matching. ``title_map`` maps a source title string to the desired output + title. Episodes are matched on their ``title`` (the show name), Movies and Songs on + their ``name``. Returns the same collection for convenient chaining. + """ + if not title_map or not titles: + return titles + + def remap_one(title: Title_T) -> None: + attr = "title" if isinstance(title, Episode) else "name" + current = getattr(title, attr, None) + if current and current in title_map: + setattr(title, attr, title_map[current]) + + if hasattr(titles, "__iter__"): + for title in titles: + remap_one(title) + else: + remap_one(titles) + return titles + + +__all__ = ( + "Episode", + "Series", + "Movie", + "Movies", + "Album", + "Song", + "Title_T", + "Titles_T", + "remap_titles", +) diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index e9ff580..4bc7dcb 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -655,6 +655,11 @@ services: muxing: set_title: true + # Remap service-provided titles before naming/output + # Keyed by the exact title the service returns -> desired output title. + title_map: + Service Title: Desired Title + # Example: Service with different regions per profile SERVICE_NAME: profiles: