Forward range, vcodec, quality, and best_available from client to server so services fetch the correct manifests (e.g. HDR10 instead of SDR).
Exit with error when no video tracks match the requested range filter.
Use Rich logger for remote API errors instead of plain click.ClickException.
Move output_template validation from config init to dl command so serve, search, and other non-download commands work without it configured. Fix CORS header to use X-Secret-Key (matching actual auth header). Exempt /api/health from auth for Cloudflare tunnel health checks.
Some HLS services serve master playlists without RESOLUTION or CODECS attributes, leaving video tracks with no width/height/codec info which causes crashes in by_resolutions() quality selection.
After parsing tracks in to_tracks(), any video track missing resolution or codec is now probed by fetching the first 8KB of its first TS segment and parsing the H.264/H.265 SPS NAL unit to extract the actual width, height, and codec. This approach mirrors how cat-catch/hls.js determines resolution from the media data rather than relying on playlist metadata.
mp4decrypt silently copies files unchanged when the tenc box default KID is all zeros, since none of the real KID:KEY pairs match. Add zero-KID fallback entries to both Widevine and PlayReady mp4decrypt methods, matching what Shaka Packager already does.
Also clear track.drm after HLS download when decryption was performed, preventing unnecessary double-decryption. DASH and URL descriptors already did this.
Some CBCS-encrypted content has an all-zeros default_KID in the tenc box while the real KID is only in the PSSH boxes. mp4decrypt matches keys against the tenc KID, so it silently skips decryption when the provided KID doesn't match. This adds a track ID-based key fallback when a zero KID is detected, matching the existing shaka-packager zero-KID fallback behavior.
The requests downloader used decode_content=False on raw socket reads, which skipped HTTP content-encoding decompression. Subtitle files served with Content-Encoding: gzip were saved as raw compressed bytes, then mangled by try_ensure_utf8 falling back to CP1252 decoding.
Remove decode_content=False from the raw read path — the speed gain comes from raw.read() itself, not from skipping decompression. Also add gzip/zlib magic byte detection in try_ensure_utf8 as a safety net for any edge cases where compressed data reaches encoding detection.
Replace list.pop(0) with deque.popleft() for O(1) speed tracker eviction, skip urllib3 decode chain with decode_content=False on raw reads, use running total instead of sum() for progress reporting, add explicit stream.close() on CurlSession path, replace busy-poll loop with concurrent.futures.wait(FIRST_COMPLETED), skip ThreadPoolExecutor for single-URL downloads, and DRY up duplicated raw/iter_content progress logic into a unified chunk iterator.
Fix critical bug where ThreadPoolExecutor was not actually parallelizing downloads (generator functions returned instantly, I/O ran on main thread).
Performance improvements:
Queue-based event dispatch: workers consume generators in threads, push events to a thread-safe Queue for truly parallel segment downloads
Raw socket reads (resp.raw.read) for requests.Session — 30-35% faster than iter_content, with iter_content fallback for CurlSession
File pre-allocation via truncate when Content-Length is known
Hot loop caching: time.time, f.write, stream.raw.read cached as locals
HTTPAdapter connection pooling mounted on passed sessions for reuse
Add InputBridge for interactive client-server authentication (OTP, device codes, PINs) with async auth via asyncio.to_thread, prompt polling endpoints, and cancellation support.
Server CDM mode detects CDM type from config.cdm per-service, resolves keys server-side, and returns DRM type to client for correct display. Cache files round-trip between client and server on session create/delete. Vault loading fixed for server-side key caching.
HLS/ISM/DASH DRM extraction in license handler. Serve --remote-only mode exposes only session endpoints. Clean connection error handling for unreachable servers.
When the DASH manifest provides a cenc:default_KID that differs from the PSSH's embedded KIDs, the external KID must be added to the PSSH so the license server returns keys for both. Previously, the PSSH was only modified when all existing KIDs were placeholders, causing a CEKNotFound error when the track's actual encryption KID wasn't in the license response.
Allow users to specify a bitrate range (e.g., --abitrate-range 300-400) to filter tracks within that range, with downstream selection picking the highest per language. Mutually exclusive with the existing --vbitrate/--abitrate exact match options.
mp4decrypt silently copies files unchanged when the tenc box default KID is all zeros, since none of the real KID:KEY pairs match. Add zero-KID fallback entries to both Widevine and PlayReady mp4decrypt methods, matching what Shaka Packager already does.
Also clear track.drm after HLS download when decryption was performed, preventing unnecessary double-decryption. DASH and URL descriptors already did this.
Replace 4 separate downloaders (requests, curl_impersonate, aria2c, n_m3u8dl_re) with a single optimized requests downloader with adaptive chunk sizing and session passthrough for TLS fingerprinting support.
- Adaptive chunk sizing (512KB-4MB) based on content length, up from fixed 1KB
- Buffered writes (1MB buffer) for improved I/O throughput
- Session passthrough: accepts both requests.Session and CurlSession
- Per-call speed tracking with rolling window (fixes cross-track speed bleed)
- Worker count default capped at 16
- Removed all downloader.__name__ special-casing from manifest parsers
- Removed aria2c/curl_impersonate/n_m3u8dl_re downloader modules
- Deprecated downloader config key in unshackle.yaml
Compress manifest XML, cache files, and cookies with zlib before base64 encoding, reducing /tracks payload by 83-95%. Add configurable compression_level in serve config (default: 1, fast).
Enable aiohttp gzip middleware for transport-level compression on all JSON responses. Set User-Agent to unshackle/<version> for remote client requests. Add remote-aware status messages for auth, titles, and tracks.
Server-side:
- Add server_cdm mode: server handles full CDM licensing using its own devices, returns KID:KEY pairs instead of raw license bytes
- Support batch license resolution for multiple tracks in one request
- Extract DRM from manifest ContentProtection when track.drm is empty
- Serialize DASH/ISM manifest XML as base64 in /tracks response
- Include session cookies/headers and server_cdm_type in /tracks response
- Detect server CDM type from actual track DRM + configured devices
- Check server region against client_region to skip unnecessary proxy
- Support decrypt_labs and custom_api remote CDMs for both WV and PR
Client-side:
- Re-parse DASH/ISM manifests locally from base64 to populate track.data
- Match remote tracks to re-parsed tracks by ID with attribute fallback
- Copy DRM objects from re-parsed manifests to remote tracks
- Pre-fetch keys via resolve_server_keys() before downloads start
- Fallback per-track licensing via _proxy_license during download
- Apply session cookies/headers from server for CDN access
- Apply downloader/decryption config directly for remote services
- Preserve pre-injected content_keys during DASH DRM override
- Skip redundant CDM calls when all KIDs already have keys
Docs:
- Add comprehensive remote-services-flow.md with Mermaid diagrams covering proxy mode, server-CDM mode, manifest transfer, and config
Standardize on X-Secret-Key across all endpoints so RemoteClient, pywidevine CDM, and api-only mode all use the same auth header. Adds lightweight middleware for --api-only mode without pywidevine dep.
Add RemoteService adapter that proxies auth, titles, tracks, and DRM licensing to a remote serve instance while running the full dl pipeline locally. Includes per-session cache isolation, PSSH forwarding for PlayReady/Widevine licensing, and session cleanup on completion.
Fixes issues introduced in 15acaea where CC extraction only used the first manifest entry and ignored --s-lang filtering entirely. Now all CC languages from the HLS manifest are iterated and filtered against --s-lang using the same match logic as regular subtitle selection.
Add --worst CLI flag to select the lowest bitrate video track within a specified resolution (e.g. --worst -q 720). Requires -q/--quality.
Add shield_okhttp TLS fingerprint preset for NVIDIA SHIELD Android TV with OkHttp 4.11 JA3 signature.
Include StreamIndex Name and Url attributes in the track ID hash to disambiguate tracks that share the same codec, language, bitrate, and QualityLevel index.