mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-06-10 03:02:09 +00:00
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:
@@ -27,7 +27,7 @@ EXAMPLE:
|
|||||||
|
|
||||||
You can override many global configuration options on a per-service basis by nesting them under the
|
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`,
|
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
|
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.
|
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`,
|
Note: unshackle uses a single unified `requests`-based downloader. The legacy `aria2c`,
|
||||||
`n_m3u8dl_re`, and `curl_impersonate` override sections have been removed.
|
`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
|
### Service Class Conventions
|
||||||
|
|
||||||
Each service directory under `unshackle/services/` exports a class extending
|
Each service directory under `unshackle/services/` exports a class extending
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from unshackle.core.credential import Credential
|
|||||||
from unshackle.core.drm import drm_from_dict
|
from unshackle.core.drm import drm_from_dict
|
||||||
from unshackle.core.manifests import DASH, HLS, ISM
|
from unshackle.core.manifests import DASH, HLS, ISM
|
||||||
from unshackle.core.remote_service import RemoteService, _build_title, _resolve_proxy
|
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 import Audio, Chapter, Chapters, Tracks, Video
|
||||||
from unshackle.core.tracks.track import Track
|
from unshackle.core.tracks.track import Track
|
||||||
|
|
||||||
@@ -123,7 +123,9 @@ class ImportService:
|
|||||||
return self.titles
|
return self.titles
|
||||||
|
|
||||||
def get_titles_cached(self, title_id: Optional[str] = None) -> Titles_T:
|
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:
|
def get_tracks(self, title: Title_T) -> Tracks:
|
||||||
"""Reconstruct the title's tracks from the export.
|
"""Reconstruct the title's tracks from the export.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from unshackle.core.config import config
|
|||||||
from unshackle.core.console import console
|
from unshackle.core.console import console
|
||||||
from unshackle.core.constants import AnyTrack
|
from unshackle.core.constants import AnyTrack
|
||||||
from unshackle.core.credential import Credential
|
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.episode import Episode, Series
|
||||||
from unshackle.core.titles.movie import Movie, Movies
|
from unshackle.core.titles.movie import Movie, Movies
|
||||||
from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video
|
from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video
|
||||||
@@ -573,7 +573,13 @@ class RemoteService:
|
|||||||
return self._titles
|
return self._titles
|
||||||
|
|
||||||
def get_titles_cached(self, title_id: str = None) -> Titles_T:
|
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:
|
def get_tracks(self, title: Title_T) -> Tracks:
|
||||||
title_id = str(title.id)
|
title_id = str(title.id)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from unshackle.core.credential import Credential
|
|||||||
from unshackle.core.drm import DRM_T
|
from unshackle.core.drm import DRM_T
|
||||||
from unshackle.core.search_result import SearchResult
|
from unshackle.core.search_result import SearchResult
|
||||||
from unshackle.core.title_cacher import TitleCacher, get_account_hash, get_region_from_proxy
|
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 import Chapters, Tracks
|
||||||
from unshackle.core.tracks.video import Video
|
from unshackle.core.tracks.video import Video
|
||||||
from unshackle.core.utils.ip_info import get_ip_info
|
from unshackle.core.utils.ip_info import get_ip_info
|
||||||
@@ -482,7 +482,7 @@ class Service(metaclass=ABCMeta):
|
|||||||
else:
|
else:
|
||||||
# If we can't determine title_id, just call get_titles directly
|
# If we can't determine title_id, just call get_titles directly
|
||||||
self.log.debug("Cannot determine title_id for caching, bypassing cache")
|
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
|
# Get cache control flags from context
|
||||||
no_cache = False
|
no_cache = False
|
||||||
@@ -495,7 +495,7 @@ class Service(metaclass=ABCMeta):
|
|||||||
account_hash = get_account_hash(self.credential)
|
account_hash = get_account_hash(self.credential)
|
||||||
|
|
||||||
# Use title cache to get titles with fallback support
|
# 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),
|
title_id=str(title_id),
|
||||||
fetch_function=self.get_titles,
|
fetch_function=self.get_titles,
|
||||||
region=self.current_region,
|
region=self.current_region,
|
||||||
@@ -503,6 +503,17 @@ class Service(metaclass=ABCMeta):
|
|||||||
no_cache=no_cache,
|
no_cache=no_cache,
|
||||||
reset_cache=reset_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
|
@abstractmethod
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
def get_tracks(self, title: Title_T) -> Tracks:
|
||||||
|
|||||||
@@ -8,4 +8,40 @@ Title_T = Union[Movie, Episode, Song]
|
|||||||
Titles_T = Union[Movies, Series, Album]
|
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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -655,6 +655,11 @@ services:
|
|||||||
muxing:
|
muxing:
|
||||||
set_title: true
|
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
|
# Example: Service with different regions per profile
|
||||||
SERVICE_NAME:
|
SERVICE_NAME:
|
||||||
profiles:
|
profiles:
|
||||||
|
|||||||
Reference in New Issue
Block a user