feat(naming): per-service title_map remapping (#106)

Add services.<TAG>.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).
This commit is contained in:
imSp4rky
2026-05-26 14:08:52 -06:00
parent f4544b4a70
commit 1cb0e4b766
6 changed files with 92 additions and 9 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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.<TAG>.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)

View File

@@ -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.<TAG>`` 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:

View File

@@ -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",
)

View File

@@ -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: