# Service Architecture This page is the conceptual map of the internal object model that a **service author** works against. It ties together the moving parts: the `Service` base class, the `Title` hierarchy a service returns, the `Track` model those titles carry, the manifest parsers that make tracks, the DRM hooks that license and decrypt them, and the request and HTTP session objects wired up for you at construction time. !!! info "Who this page is for" This is a **developer reference**. If you only want to *download* titles, you never touch these classes directly. See [Downloading](../guide/downloading.md) and the [CLI reference](../guide/cli-reference.md) instead. If you write or read a service plugin, this page explains what each object is and how the pieces connect. For a guided, step-by-step walkthrough, see [Creating a Service](creating-a-service.md). For exact signatures, see the auto-generated [Service API](../reference/api/service.md), [Titles API](../reference/api/titles.md), and [Tracks API](../reference/api/tracks.md). ## The shape of a download A service is a translator between one streaming platform and unshackle's generic pipeline. It never downloads or decrypts anything itself. It only answers questions. The `dl` command drives the whole flow and only ever calls three required methods on your service, in order: ```text Service.get_titles → which movies / episodes / songs exist │ Service.get_tracks → fetch a manifest for one title ... │ manifest.to_tracks → ... parsed into Video / Audio / Subtitle tracks │ Track.download → fetch + merge segments (core-driven) │ DRM license + decrypt → service license hooks → CDM → decrypt tool │ Tracks.mux → mkvmerge → tagged .mkv (movie / episode) │ a Song skips the muxer and keeps its own container ``` 1. **`get_titles()`** returns a `Titles` collection: the movies, episodes, or songs the platform exposes for the requested ID. 2. **`get_tracks(title)`** returns a `Tracks` object for a single chosen title. In practice this means fetching a manifest and handing it to a parser, which builds the `Video`/`Audio`/`Subtitle` objects. 3. **`get_chapters(title)`** returns any chapter markers. The core does everything after that (track selection, licensing, decryption, repacking, subtitle conversion, and muxing) with the objects your service returned. Your job is to give accurate `Title` and `Track` objects. The framework does the rest. ## The `Service` base class Every service subclasses `unshackle.core.service.Service`, an abstract base class. Two kinds of members matter: **class variables** that give the details of the service, and **methods** that the pipeline calls. ### Class variables 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, 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. | | `GEOBLOCK` | `tuple[str, ...]` | ISO region codes where the service refuses to work; every other region is allowed. Use it for a service that works worldwide except in its home market. | | `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()`. | | `ANONYMOUS_SEARCH` | `bool` | `search()` needs no login and no state from `authenticate()`. A search then skips the cookie load, `authenticate()` and the cookie save. Default `False`. | | `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 unshackle names episodes by air date. A title's own `daily` flag overrides it. | ### Required methods These are abstract. A service will not load unless it defines all three: - `get_titles() -> Titles_T`: find the title collection for the requested ID. - `get_tracks(title) -> Tracks`: assemble the track set for one title. - `get_chapters(title) -> Chapters`: return chapter markers (may be empty). ### Optional methods and hooks - **`authenticate(cookies=None, credential=None)`**: the base implementation loads a `CookieJar` into `self.session.cookies` and stores `self.credential`. Override it to do a real login handshake. - **`search()`**: raises `NotImplementedError` by default. Write it so that the `search` command can operate. unshackle calls `authenticate()` first, unless the service sets `ANONYMOUS_SEARCH`. - **DRM license hooks**: `get_widevine_service_certificate`, `get_widevine_license`, `get_playready_license` (delegates to Widevine by default), and `get_clearkey_license` (returns `None` by default). See [DRM hooks](#drm-hooks) below. - **`request_input(prompt)`**: prompts the user. It goes through the remote-dl input bridge, the REST download job relay, or the rich console. - **`wait_for_approval(message, check, timeout, interval=3.0)`**: shows a message, then calls `check` until it returns a value. Use it for a login that the user approves on another device, such as a pair code. A remote-dl session shows the message as a log line, and a REST download job shows it as the job's `notice`. - **Event callbacks**: `on_segment_downloaded`, `on_track_downloaded`, `on_track_decrypted`, `on_track_repacked`, `on_track_multiplex` let a service react to pipeline events (for example, MonaLisa decrypts each segment in `on_segment_downloaded`). - **`resolve_server_keys(title)`**: an escape hatch for services whose decryption keys are resolved server-side *ahead of* the normal per-track license flow. Unlike everything else here it is **not declared on the `Service` base class at all**: `dl.py` probes for it with `hasattr` and calls it (once, after track selection) only if your service defines it. Use it when your service must fetch or inject keys in bulk before download, separate from the per-track license callbacks below. ### What `__init__` wires up for you You rarely override `__init__`. The base constructor takes the Click context and sets up the objects your methods rely on: | Attribute | What it is | |---|---| | `self.config` | The per-service config slice (see [Request and HTTP session objects](#request-and-http-session-objects)). | | `self.log` | A `logging.Logger` named after your class. | | `self.session` | A ready-to-use HTTP session (headers, retries, and proxy applied). Send every request through it: with a proxy active, a request from service code that does not go through the proxy logs a warning. A separate HTTP session that goes direct on purpose sets `proxy_exempt = True`. | | `self.cache` | A generic key/value `Cacher` namespaced to the service. | | `self.title_cache` | A region- and account-aware `TitleCacher`. | | `self.cache_dir` | `config.directories.cache / `. | | `self.credential` | The active `Credential`, or `None` until authenticated. | | `self.track_request` | A `TrackRequest` describing the requested codecs, ranges, and best-available flag (see below). | The constructor also resolves proxies: it reads `--proxy` and the `proxy_query` and `proxy_provider` context params that `dl` derives from it, consults the per-service `proxy_map`, and, if `GEOFENCE` is set and you did not give an explicit proxy, does a live IP check and auto-fetches a proxy to `GEOFENCE[0]` when it detects a geoblock. A `GEOBLOCK` hit with no `GEOFENCE` stops the run with a "not available in your region" error, because there is no main region to fetch a proxy for; pass `--proxy` yourself. A proxy whose exit is in a `GEOBLOCK` region stops the run the same way. See [Proxies and VPN](../guide/proxies-and-vpn.md). !!! note "`TrackRequest`" `self.track_request` is a small dataclass built from the CLI: `codecs` (from `--vcodec`, empty means "accept any"), `ranges` (from `--range`, default `[Video.Range.SDR]`), `best_available`, and `generate_dv` (from `--generate-dv`). Use it in `get_tracks` to decide which variants to fetch. A service that fetches the HYBRID variants itself must also fetch HDR10P when `generate_dv` is set and the title has no DV track, because the conversion needs an HEVC HDR10+ base. ## Titles and the `Title` hierarchy `get_titles()` returns a **title collection**, one of the `Titles_T` types. Each collection holds one or more `Title` objects (`Title_T`). There are three parallel families, one per media kind: | Media kind | Item (`Title_T`) | Collection (`Titles_T`) | |---|---|---| | Movies | `Movie` | `Movies` | | TV | `Episode` | `Series` | | Music | `Song` | `Album`, and `Music` (the base collection) | 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 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 (`"The unique ID is not large enough, clash likely."`). Pick a stable, sufficiently unique identifier from the platform. ### Constructors at a glance Each subclass adds its own required fields on top of `id_` and `service`: === "Movie" ```python Movie(id_, service, name, year=None, language=None, data=None, description=None) ``` You must give `name`. `year` is optional, but it must be a positive integer if you give it. === "Episode" ```python Episode(id_, service, title, season, number, name=None, year=None, language=None, data=None, description=None, air_date=None) ``` `title` is the **show** name. You must give `season` and `number`, and `0` is a valid value. unshackle converts an `air_date` to a `date` so the `{date}` naming token stays clean, and it discards a placeholder episode `name` like `"Episode 4"` automatically. === "Song" ```python Song(id_, service, name, artist, album, track, disc=1, year=None, language=None, data=None, ...) ``` You must give `name`, `artist`, `album`, and `track`, and unshackle validates them. `disc` defaults to 1, and `year` is optional. A single therefore needs no `disc`, and an undated release needs no `year`. A `Song` also takes optional metadata keywords that the tagger and the naming templates read: `album_artist`, `release_type`, `total_tracks`, `total_discs`, `genre`, `explicit`, `isrc`, `upc`, `copyright`, `label`, `lyrics`, `synced_lyrics`, `lyrics_suffix`, and `artwork_url`. Read the class for the current list. Give `lyrics` as plain text: the tagger embeds it as is. With `synced_lyrics` (timed LRC or TTML) and `lyrics_suffix` (`".lrc"`, `".ttml"`), unshackle also saves the timed lyrics beside the final audio file under that suffix. You can set these fields in `get_tracks()` too, and a remote client gets them back. The tagger also falls back to `Song.data` for fields the constructor did not get, such as the composer or a full release date. It reads the top level of that dict and a nested `metadata` sub-dict, with the nested keys winning, so either shape works. From `get_tracks()`, a music service gives **one `Audio` track for each codec and bitrate it offers for a `Song`**. The framework then selects one with its usual audio options, `-a`/`--acodec` and `--abitrate`, and its usual audio sorting. A movie or an episode uses the same options. `-q`/`--quality` sets the video height only and does not apply to music. `Audio` has no `bit_depth` or `sample_rate` field. Therefore `bitrate` separates a hi-res FLAC from a CD FLAC, and the audio sort already ranks it. A `Song` is an ordinary `Title` on the one generic download path. unshackle has no music-specific service API and no separate music download loop. The `GROUP_AUDIO_DOWNLOADS` flag, `get_music_track_options` and `get_music_collection_label` no longer exist, and a music service does not need them. !!! warning "Restore the container in `on_track_downloaded`" Every track downloads to a temp file named `Audio_.mp4` whatever it holds, and the final move keeps that suffix. A `Song` skips the muxer, so its download **is** the delivered file, and the tagger picks its metadata format from the suffix. Rename the track path to the true container in `on_track_downloaded`, or a FLAC lands as an untaggable `.mp4`. The event fires twice, once before decryption and once at the end, so wait until `track.drm` is clear before you rename. The **MUSIC_EXAMPLE** reference service, at `unshackle/services/MUSIC_EXAMPLE/`, shows the full music surface. ### Title remapping After the service fetches the titles (and after any cache), the framework applies `remap_titles(titles, title_map)` with the per-service `title_map` config. This does an **exact-string** rename (it compares `Episode`s on their show `title`, and `Movie`s and `Song`s on their `name`), so you can change a platform's odd naming to what your library expects. `get_titles_cached()` also cache-wraps the titles, and it is region- and account-aware. See the [TitleCacher](#request-and-http-session-objects) notes. The ordering here is deliberate on both sides. The framework applies `title_map` **after** the title cache so that editing the map takes effect immediately, without forcing a cache reset, and **before** metadata enrichment (`--enrich`) so that an explicit enrich override still wins over a mapped name. ## Tracks and the `Track` model A `Tracks` object is the container `get_tracks` returns. It has typed buckets (`videos`, `audio`, `subtitles`, `chapters`, and `attachments`) plus a `manifest_url`. Iterating a `Tracks` yields **only** video, audio, and subtitle tracks (not chapters or attachments), and the canonical display/mux order is Video → Audio → Subtitle → Chapter → Attachment. !!! warning "Track IDs must be deterministic" `Tracks.add` refuses duplicate IDs. Track IDs are CRC32-derived from the track's identity (URL, codec, language, bitrate) and **must be stable across requests**, never random. The manifest parsers already do this correctly. If you assemble tracks by hand, keep IDs deterministic or `add` will raise. ### The base `Track` `Track` is the base for `Video`, `Audio`, and `Subtitle`. (`Chapter`, `Chapters`, and `Attachment` are standalone classes, **not** `Track` subclasses.) Every track records its delivery method in `Track.Descriptor`, which tells unshackle how to download it: | Descriptor | Delivery | |---|---| | `URL` | A single direct URL. | | `HLS` | HTTP Live Streaming. | | `DASH` | Dynamic Adaptive Streaming over HTTP. | | `ISM` | Smooth Streaming. | The main attributes include `url` (a string or list of strings), `language` (a `langcodes.Language`), `drm` (an optional iterable of DRM objects), `needs_repack`, and descriptor-specific state stashed in `track.data["hls"]` / `track.data["dash"]`. If you do not give a `name`, unshackle derives a human-readable name from the language. ### `Video` `Video` adds `codec`, `range`, `bitrate`, `width`, `height`, `fps`, `scan_type`, and closed-caption descriptors. The relevant enums: - **`Video.Codec`**: `AVC` (H.264), `HEVC` (H.265), `VC1`, `VP8`, `VP9`, `AV1`. - **`Video.Range`**: `SDR`, `HLG`, `HDR10`, `HDR10P` (HDR10+), `DV` (Dolby Vision), and `HYBRID`. `HYBRID` is special: it tells the pipeline to select both an HDR10(+) base and a DV track and merge the DV RPU onto the HDR10 track with `dovi_tool` (see [Dolby Vision hybrid](#dolby-vision-and-hybrid)). !!! note "Bitrate units" Both `Video` and `Audio` store `bitrate` in **bits per second** (rounded up). The string form displays kb/s, and `--vbitrate` and `--abitrate` compare in kb/s. ### `Audio` `Audio` adds `bitrate`, `channels`, `joc` (Joint Object Coding, the Atmos indicator), and `descriptive`. `Audio.Codec` covers `AAC`, `AC3` (`DD`), `EC3` (`DD+`), `AC4`, `OPUS`, `OGG` (Vorbis), `DTS`, `DTSX`, `ALAC`, and `FLAC`. The `atmos` property is `True` when `joc` is set (or when `extra` flags it). ### `Subtitle` `Subtitle` adds three mutually-constrained flags: `cc` (closed captions), `sdh` (subtitles for the deaf and hard-of-hearing), and `forced`. A track cannot be both `cc` and `sdh`, and `forced` cannot combine with either. `Subtitle.Codec` includes `SubRip` (SRT), `WebVTT` (VTT), `SubStationAlpha`/`SubStationAlphav4` (SSA/ASS), `TimedTextMarkupLang` (TTML), `SAMI`, and the box-encapsulated DASH formats `fTTML` (STPP) and `fVTT` (WVTT). Subtitles receive post-download normalization (segment merging, WebVTT sanitization, format conversion, and optional SDH stripping), all documented for users in [Subtitles](../guide/subtitles.md). ### Chapters and attachments - **`Chapter(timestamp, name=None)`**: a single marker. The timestamp accepts `HH:MM:SS[.mmm]`, integer milliseconds, or float seconds. - **`Chapters`**: a sorted collection that auto-inserts a `00:00:00.000` chapter and reads/writes OGM Simple format. - **`Attachment`**: a font or image attached to the mux (for example, fonts an ASS subtitle references), built from a local `path` or a `url`. ### Selection, sorting, and muxing `Tracks` is also the selection engine the `dl` command uses. It offers `sort_videos` / `sort_audio` / `sort_subtitles` (P2P-standard ordering), predicate-based `select_*` filters, `by_resolutions`, and `by_language`. When downloads finish, the same object multiplexes everything: **`Tracks.mux(title, ...)`** builds and runs an `mkvmerge` command, applying language, default-track, forced, and hearing-impaired flags, attaching chapters and fonts, and writing a `.muxed.mkv` (or `.mka` / `.mks` for audio- or subtitle-only sets). As a service author you never call `mux` yourself. You only return correct tracks. ## Manifest parsers Most services do not assemble tracks by hand. They give a manifest to one of three parsers in `unshackle.core.manifests`. Each parser has the same three-part contract: | Format | Class | Notes | |---|---|---| | MPEG-DASH (`.mpd`) | `DASH` | Parses ``; enumerates tracks from the first content period. | | HLS (`.m3u8`) | `HLS` | Requires a **variant (master) playlist**, not a media playlist. | | Smooth Streaming (`.ism`) | `ISM` | Synthesizes init segments from `CodecPrivateData`. | ### The parser contract 1. **Construct**: `DASH.from_url(url, session=...)` or `DASH.from_text(text, url)` (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. 3. **`download_track(...)`**: a staticmethod the core calls to fetch, decrypt, and merge segments. You do not call it. You only return the tracks it will operate on. A typical `get_tracks` therefore looks like fetching the manifest text with `self.session` and returning `DASH.from_text(mpd, url).to_tracks(language=title.language)`. !!! tip "Pass the original language" DASH and HLS `to_tracks` raise `ValueError` if they cannot derive a track's language and you gave no valid fallback. Always pass the title's original recorded language. For HLS, `to_tracks` uses this fallback directly for the primary track, because HLS variants carry no language. ### Segment-filtering hooks - **HLS**: set `track.OnSegmentFilter(segment)` to drop unwanted segments (ads/bumpers). A dropped segment is invisible to the whole download: unshackle ignores its `EXT-X-KEY` and its `EXT-X-DISCONTINUITY`, so a clear ad break inside an encrypted track does not disturb decryption. - **DASH**: pass a `period_filter` callable to `to_tracks` to drop whole periods. unshackle automatically drops the caching-breadth periods that some services send, and the non-`content` segment types. ### Format gotchas worth knowing - **DASH** enumerates tracks from only the *first* content period, but downloads segments from *all* content periods matching the representation. The parser reads `SegmentTemplate`, `SegmentList`, and `SegmentBase` addressing, and `$Number%0Nd$` padding. - **HLS** re-fetches the media playlist at download time, recovers `SUPPLEMENTAL-CODECS` from raw text (python-m3u8 drops it) to detect Dolby Vision composite streams, and uses the media-sequence-number IV for AES-128 keys that have no explicit IV. - **ISM** has no list of segments and no init segments: the parser synthesizes segment URLs from the `` fragment timeline, and rebuilds a `moov`/`ftyp` init box from `CodecPrivateData` (supported for AVC, HEVC, DV-over-HEVC, AAC, EC-3, and TTML). A track id covers the manifest URL, so a title you build from one ISM manifest per codec or range keeps every variant: `Tracks.add` no longer reads the second manifest's tracks as duplicates of the first. ## DRM hooks If a track carries DRM, the framework licenses and decrypts it, but it needs your service to talk to the platform's license server. unshackle recognizes five DRM systems: | DRM class | System | License flow | |---|---|---| | `Widevine` | Widevine | CDM challenge → license server → content keys (`.wvd` device) | | `PlayReady` | PlayReady | CDM challenge (WRM header) → license server → content keys (`.prd` device) | | `ClearKeyCENC` | W3C `org.w3.clearkey` over CENC | JSON KID request → JWK set; no CDM | | `ClearKey` | HLS AES-128 | content key fetched directly from a URI; no CDM, no challenge | | `MonaLisa` | Proprietary WASM DRM | keys extracted locally; no license server | !!! warning "`ClearKey` vs `ClearKeyCENC`" Despite the shared prefix these are entirely different systems. `ClearKey` is HLS AES-128, decrypted in-process. `ClearKeyCENC` is the W3C EME JWK flow over MPEG-CENC, decrypted with shaka-packager or mp4decrypt like Widevine. ### The service-side hooks The framework supplies the CDM and the decryption backend. Your service gives the network calls that must carry the CDM's challenge. Override the hooks that your platform needs: | Hook | Return | Purpose | |---|---|---| | `get_widevine_service_certificate(title, track, challenge)` | cert bytes | Optional privacy-mode service certificate. | | `get_widevine_license(title, track, challenge)` | license bytes | POST the Widevine challenge to the license server, return the response. | | `get_playready_license(title, track, challenge)` | license | Same for PlayReady; defaults to delegating to Widevine. | | `get_clearkey_license(title, track, challenge)` | dict / str / bytes / `None` | For `ClearKeyCENC`; return `None` to let the framework POST to the manifest `Laurl`. | You do not open CDM sessions or operate shaka-packager yourself. The `Widevine`/`PlayReady` classes drive the CDM handshake and call the decryption backend. Your hook is only the "send this challenge, give me back the license" step. ### Decrypting a cbcs file yourself Some tracks cannot use the framework's decryption: a single cbcs file whose sample entries share a zero default KID, or a sample entry whose content key no CDM can license and only a separate process can use. For these, set no `drm` on the track and override two methods: - `decrypts_samples(track)` returns `True` for such a track. - `get_sample_decrypter(track, entry)` returns, for the 1-based `stsd` entry, the 16-byte content key or a `cbcs.SampleDecrypter`. A `SampleDecrypter` gets the protected bytes of each sample of one fragment, cut to whole AES blocks, and returns plaintexts of the same lengths. It handles full-sample cbcs only, not pattern encryption. When the download of the track ends, unshackle calls `unshackle.core.drm.cbcs.decrypt_file`, and the progress row shows "Decrypting". Your `on_track_downloaded` runs before this and gets the encrypted file. `decrypt_file` writes the plaintext back in place and removes the protection boxes, so the muxer reads a clear file. In a remote session `get_sample_decrypter` runs on the server: the client sends the samples through the `samples` route, so a decrypter that needs a process on the server also works for a remote client. ### How unshackle chooses a CDM and finds keys CDM selection is config-driven: the `cdm` map resolves a service tag (and optionally the track quality or DRM type) to a device name, which `load_cdm` instantiates as a local `.wvd`/`.prd` or a remote CDM. Before ever requesting a license, the framework tries an in-process cache of content keys and the configured **key vaults**. Only unresolved KIDs trigger an actual license request, and the framework writes newly licensed keys back to every vault. This is why the same content key is rarely re-licensed twice. Users configure all of this in [DRM and CDM](../guide/drm-and-cdm.md) and [Key vaults](../guide/vaults.md). The [DRM API reference](../reference/api/drm.md) gives the API surface. ### Dolby Vision and hybrid Two DV mechanisms sit on top of the track model. **DV fixup** re-exposes DV signalling on HLS and DASH streams that carry a valid DV RPU but advertise plain HEVC, with DV only in the supplemental codecs (`dv_compatible_bitstream`). `--list` shows such a track as `HDR10+DV` or `HDR10P+DV`. **Hybrid** processing (`Video.Range.HYBRID`) selects an HDR10(+) base and a DV donor, extracts the RPU with `dovi_tool`, and injects it onto the base layer, to make a single track that carries both HDR10 metadata and the DV RPU. Both are automatic once the pipeline selects the right tracks. A service only needs to give accurate ranges. The fixup replaces the track file with a raw HEVC file. A raw HEVC file has no duration and FFmpeg cannot seek in it. When a hybrid base also gets the fixup, the pipeline keeps the container file of that track until its hybrid step is complete, and the hybrid step reads the container file. ## Request and HTTP session objects Everything a service does over the network flows through objects the base constructor prepared, all derived from the Click context (`ctx`) that `dl` passes in. - **`self.config`**: your service's slice of `unshackle.yaml`, i.e. `config.services[]`. This is a plain dict. Use it for per-service settings like base URLs, an API key, or a `title_map`. See [Configuration](../getting-started/configuration-file.md). - **`self.session`**: a `requests.Session` with `config.headers` applied, a retry policy (`total=5`, backoff, retry on 429/5xx), and any resolved proxy already mounted. Pass this HTTP session into the manifest parsers and DRM calls so headers, cookies, and proxies flow everywhere. (unshackle also ships an rnet-based TLS-fingerprinting HTTP session that the newer `session()` factory uses and that the `network` config key configures.) - **`self.credential`**: the active `Credential` (`username:password[:extra]`), populated by `authenticate`. unshackle uses its `sha1` as the account hash for cache keys. - **`self.cache`**: a generic `Cacher`: JSON key/value storage under the service's cache dir, with CRC32 integrity checks, versioning, and optional expiry (auto-derived from a JWT `exp` claim when present). Good for auth tokens and API responses. - **`self.title_cache`**: a `TitleCacher` that wraps `get_titles` results, keyed by title ID, region, and account hash. It serves fresh data within `title_cache_time`, falls back to stale data within `title_cache_max_retention` when an API call fails, and `--no-cache` bypasses it. - **`self.track_request`**: the `TrackRequest` describing what the user asked for (codecs, ranges, best-available), for use inside `get_tracks`. !!! example "A minimal service skeleton" ```python title="services/EXMP/__init__.py" from unshackle.core.service import Service from unshackle.core.manifests import DASH from unshackle.core.titles import Movie, Movies class EXMP(Service): ALIASES = ("example",) GEOFENCE = ("us",) def get_titles(self) -> Movies: data = self.session.get(f"https://api.example.com/title/{self.title}").json() return Movies([ Movie( id_=data["id"], service=self.__class__, name=data["name"], year=data.get("year"), language=data.get("language"), ) ]) def get_tracks(self, title) -> "Tracks": mpd_url = self.session.get(...).json()["manifest"] mpd = self.session.get(mpd_url).text return DASH.from_text(mpd, mpd_url).to_tracks(language=title.language) def get_chapters(self, title): return [] def get_widevine_license(self, *, challenge, title, track): return self.session.post("https://lic.example.com/wv", data=challenge).content ``` The directory name (`EXMP`) must match the class name exactly. That stem is the service tag. Put your service in one of the `directories.services` paths. ## Where to go next - **[Creating a Service](creating-a-service.md)**: a guided walkthrough that builds on this model. - **[Service API](../reference/api/service.md)**: exact signatures for the `Service` base class. - **[Titles API](../reference/api/titles.md)** and **[Tracks API](../reference/api/tracks.md)**: the full object reference. - **[DRM API](../reference/api/drm.md)**: the DRM and CDM classes in detail. - **[Downloading](../guide/downloading.md)**: how the flags a user passes map onto track selection.