├── unshackle ├── vaults │ └── __init__.py ├── binaries │ └── placehere.txt ├── commands │ ├── __init__.py │ ├── cfg.py │ └── serve.py ├── core │ ├── utils │ │ ├── __init__.py │ │ ├── osenvironment.py │ │ ├── xml.py │ │ ├── gen_esn.py │ │ ├── subprocess.py │ │ ├── collections.py │ │ └── sslciphers.py │ ├── __init__.py │ ├── manifests │ │ ├── __init__.py │ │ └── m3u8.py │ ├── api │ │ ├── __init__.py │ │ └── download_worker.py │ ├── cdm │ │ └── __init__.py │ ├── downloaders │ │ └── __init__.py │ ├── proxies │ │ ├── __init__.py │ │ ├── proxy.py │ │ ├── basic.py │ │ ├── hola.py │ │ ├── windscribevpn.py │ │ ├── surfsharkvpn.py │ │ └── nordvpn.py │ ├── drm │ │ ├── __init__.py │ │ └── clearkey.py │ ├── titles │ │ ├── __init__.py │ │ ├── title.py │ │ └── song.py │ ├── tracks │ │ ├── __init__.py │ │ ├── chapter.py │ │ ├── attachment.py │ │ └── chapters.py │ ├── constants.py │ ├── commands.py │ ├── search_result.py │ ├── vault.py │ ├── binaries.py │ ├── __main__.py │ ├── events.py │ ├── services.py │ ├── vaults.py │ ├── credential.py │ └── cacher.py ├── __main__.py ├── services │ ├── YTBE │ │ └── config.yaml │ ├── DSCP │ │ └── config.yaml │ ├── PTHS │ │ ├── config.yaml │ │ └── __init__.py │ ├── NF │ │ └── MSL │ │ │ ├── MSLObject.py │ │ │ ├── MSLKeys.py │ │ │ └── schemes │ │ │ ├── __init__.py │ │ │ ├── UserAuthentication.py │ │ │ ├── EntityAuthentication.py │ │ │ └── KeyExchangeRequest.py │ ├── TUBI │ │ └── config.yaml │ ├── ITV │ │ └── config.yaml │ ├── SEVEN │ │ └── config.yaml │ ├── CTV │ │ └── config.yaml │ ├── CBC │ │ └── config.yaml │ ├── RTE │ │ └── config.yaml │ ├── AUBC │ │ └── config.yaml │ ├── ROKU │ │ └── config.yaml │ ├── TEN │ │ └── config.yaml │ ├── TVNZ │ │ └── config.yaml │ ├── CBS │ │ └── config.yaml │ ├── CWTV │ │ └── config.yaml │ ├── PLUTO │ │ └── config.yaml │ ├── ARD │ │ ├── config.yaml │ │ └── __init__.py │ ├── EXAMPLE │ │ └── config.yaml │ ├── UKTV │ │ └── config.yaml │ ├── MAX │ │ └── config.yaml │ ├── MTSP │ │ ├── config.yaml │ │ └── __init__.py │ ├── NRK │ │ ├── config.yaml │ │ └── __init__.py │ ├── NPO │ │ └── config.yaml │ ├── PLEX │ │ └── config.yaml │ ├── SPOT │ │ └── config.yaml │ ├── NBLA │ │ └── config.yaml │ ├── README.md │ ├── TPTV │ │ └── config.yaml │ ├── STV │ │ └── config.yaml │ ├── DSNP │ │ └── config.yaml │ ├── ALL4 │ │ └── config.yaml │ ├── PCOK │ │ └── config.yaml │ ├── CR │ │ └── config.yaml │ ├── RKTN │ │ └── config.yaml │ ├── MY5 │ │ └── config.yaml │ ├── ZDF │ │ └── config.yaml │ └── iP │ │ └── config.yaml └── utils │ ├── osenvironment.py │ └── base62.py ├── .gitattributes ├── WVDs ├── device.wvd └── device │ └── metadata.yml ├── img └── envied1.png ├── .editorconfig ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── install.bat ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── post_processing ├── post_processing.md ├── mkv_to_mp4.py └── extract_mks_subs.py ├── Install-media-tools.sh ├── pyproject.toml ├── README.md ├── UNSHACKLE_README.md └── .gitignore /unshackle/vaults/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unshackle/binaries/placehere.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unshackle/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /unshackle/core/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /unshackle/core/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.1.0" 2 | -------------------------------------------------------------------------------- /WVDs/device.wvd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinefeeder/envied/HEAD/WVDs/device.wvd -------------------------------------------------------------------------------- /img/envied1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinefeeder/envied/HEAD/img/envied1.png -------------------------------------------------------------------------------- /unshackle/__main__.py: -------------------------------------------------------------------------------- 1 | if __name__ == "__main__": 2 | from unshackle.core.__main__ import main 3 | 4 | main() 5 | -------------------------------------------------------------------------------- /unshackle/services/YTBE/config.yaml: -------------------------------------------------------------------------------- 1 | cookie: 2 | cookies\YTBE\default.txt 3 | 4 | service: 5 | services\YTBE\__init__.py 6 | -------------------------------------------------------------------------------- /unshackle/core/manifests/__init__.py: -------------------------------------------------------------------------------- 1 | from .dash import DASH 2 | from .hls import HLS 3 | from .ism import ISM 4 | 5 | __all__ = ("DASH", "HLS", "ISM") 6 | -------------------------------------------------------------------------------- /unshackle/services/DSCP/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | base_url: "https://default.{}-{}.prd.api.discoveryplus.com" 3 | 4 | client_id: "b6746ddc-7bc7-471f-a16c-f6aaf0c34d26" # androidtv 5 | -------------------------------------------------------------------------------- /unshackle/core/api/__init__.py: -------------------------------------------------------------------------------- 1 | from unshackle.core.api.routes import cors_middleware, setup_routes, setup_swagger 2 | 3 | __all__ = ["setup_routes", "setup_swagger", "cors_middleware"] 4 | -------------------------------------------------------------------------------- /unshackle/services/PTHS/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | metadata: "https://www.pathe-thuis.nl/api/movies/{movie_id}?include=editions" 3 | ticket: "https://www.pathe-thuis.nl/api/tickets/{ticket_id}" -------------------------------------------------------------------------------- /unshackle/core/cdm/__init__.py: -------------------------------------------------------------------------------- 1 | from .custom_remote_cdm import CustomRemoteCDM 2 | from .decrypt_labs_remote_cdm import DecryptLabsRemoteCDM 3 | 4 | __all__ = ["DecryptLabsRemoteCDM", "CustomRemoteCDM"] 5 | -------------------------------------------------------------------------------- /unshackle/services/NF/MSL/MSLObject.py: -------------------------------------------------------------------------------- 1 | import jsonpickle 2 | 3 | 4 | class MSLObject: 5 | def __repr__(self): 6 | return "<{} {}>".format(self.__class__.__name__, jsonpickle.encode(self, unpicklable=False)) 7 | -------------------------------------------------------------------------------- /unshackle/services/TUBI/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | content: https://uapi.adrise.tv/cms/content # https://content-cdn.production-public.tubi.io/api/v2/content 3 | search: https://search.production-public.tubi.io/api/v1/search 4 | 5 | -------------------------------------------------------------------------------- /unshackle/services/ITV/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | User-Agent: okhttp/4.9.3 3 | 4 | endpoints: 5 | login: https://auth.prd.user.itv.com/v2/auth 6 | refresh: https://auth.prd.user.itv.com/token 7 | search: https://textsearch.prd.oasvc.itv.com/search -------------------------------------------------------------------------------- /unshackle/core/downloaders/__init__.py: -------------------------------------------------------------------------------- 1 | from .aria2c import aria2c 2 | from .curl_impersonate import curl_impersonate 3 | from .n_m3u8dl_re import n_m3u8dl_re 4 | from .requests import requests 5 | 6 | __all__ = ("aria2c", "curl_impersonate", "requests", "n_m3u8dl_re") 7 | -------------------------------------------------------------------------------- /unshackle/services/SEVEN/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | user-agent: "7plus/5.25.1 (Linux;Android 8.1.0) ExoPlayerLib/2.11.7" 3 | x-swm-apikey: "kGcrNnuPClrkynfnKwG8IA/NhVG6ut5nPEdWF2jscvE=" 4 | 5 | PLATFORM_ID: "androidtv" 6 | PLATFORM_VERSION: "5.25.0.0" 7 | API_VERSION: "5.9.0.0" 8 | -------------------------------------------------------------------------------- /unshackle/core/proxies/__init__.py: -------------------------------------------------------------------------------- 1 | from .basic import Basic 2 | from .hola import Hola 3 | from .nordvpn import NordVPN 4 | from .surfsharkvpn import SurfsharkVPN 5 | from .windscribevpn import WindscribeVPN 6 | 7 | __all__ = ("Basic", "Hola", "NordVPN", "SurfsharkVPN", "WindscribeVPN") 8 | -------------------------------------------------------------------------------- /unshackle/services/CTV/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | login: https://account.bellmedia.ca/api/login/v2.1 3 | auth: Y3R2LXdlYjpkZWZhdWx0 4 | api: https://api.ctv.ca/space-graphql/graphql 5 | license: https://license.9c9media.ca/widevine 6 | search: https://www.ctv.ca/space-graphql/apq/graphql -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.{feature,json,md,yaml,yml,toml}] 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /unshackle/services/CBC/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | base_url: "https://services.radio-canada.ca" 3 | validation: "/media/validation/v2?appCode=gem&&deviceType={}&idMedia={}&manifestType={}&output=json&tech={}" 4 | api_key: "3f4beddd-2061-49b0-ae80-6f1f2ed65b37" 5 | 6 | client: 7 | id: "fc05b0ee-3865-4400-a3cc-3da82c330c23" -------------------------------------------------------------------------------- /unshackle/services/RTE/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | user-agent: Dalvik/2.1.0 (Linux; U; Android 13; SM-A536E Build/RSR1.210722.013.A2) 3 | 4 | endpoints: 5 | base_url: https://www.rte.ie 6 | feed: https://feed.entertainment.tv.theplatform.eu 7 | license: https://widevine.entitlement.eu.theplatform.com/wv/web/ModularDrm 8 | -------------------------------------------------------------------------------- /unshackle/services/AUBC/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0 3 | accept-language: en-US,en;q=0.8 4 | 5 | endpoints: 6 | base_url: https://api.iview.abc.net.au 7 | license: https://wv-keyos.licensekeyserver.com/ 8 | 9 | client: "1d4b5cba-42d2-403e-80e7-34565cdf772d" -------------------------------------------------------------------------------- /unshackle/services/ROKU/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | content: https://therokuchannel.roku.com/api/v2/homescreen/content/https%3A%2F%2Fcontent.sr.roku.com%2Fcontent%2Fv1%2Froku-trc%2F 3 | vod: https://therokuchannel.roku.com/api/v3/playback 4 | token: https://therokuchannel.roku.com/api/v1/csrf 5 | search: https://therokuchannel.roku.com/api/v1/search -------------------------------------------------------------------------------- /unshackle/core/drm/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from unshackle.core.drm.clearkey import ClearKey 4 | from unshackle.core.drm.playready import PlayReady 5 | from unshackle.core.drm.widevine import Widevine 6 | 7 | DRM_T = Union[ClearKey, Widevine, PlayReady] 8 | 9 | 10 | __all__ = ("ClearKey", "Widevine", "PlayReady", "DRM_T") 11 | -------------------------------------------------------------------------------- /unshackle/services/TEN/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | User-Agent: 10play/7.4.0.500325 Android UAP 3 | 4 | endpoints: 5 | config: https://10.com.au/api/v1/config 6 | auth: https://10.com.au/api/user/auth 7 | query: https://vod.ten.com.au/api/videos/bcquery # androidapps-v2 8 | 9 | api_key: "b918ff793563080c5821c89ee6c415c363cb36d369db1020369ac4b405a0211d" -------------------------------------------------------------------------------- /unshackle/core/titles/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from .episode import Episode, Series 4 | from .movie import Movie, Movies 5 | from .song import Album, Song 6 | 7 | Title_T = Union[Movie, Episode, Song] 8 | Titles_T = Union[Movies, Series, Album] 9 | 10 | 11 | __all__ = ("Episode", "Series", "Movie", "Movies", "Album", "Song", "Title_T", "Titles_T") 12 | -------------------------------------------------------------------------------- /unshackle/services/NF/MSL/MSLKeys.py: -------------------------------------------------------------------------------- 1 | from .MSLObject import MSLObject 2 | 3 | 4 | class MSLKeys(MSLObject): 5 | def __init__(self, encryption=None, sign=None, rsa=None, mastertoken=None, cdm_session=None): 6 | self.encryption = encryption 7 | self.sign = sign 8 | self.rsa = rsa 9 | self.mastertoken = mastertoken 10 | self.cdm_session = cdm_session 11 | -------------------------------------------------------------------------------- /unshackle/services/TVNZ/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | User-Agent: "AndroidTV/!/!" 3 | x-tvnz-api-client-id: "androidtv/!.!.!" 4 | 5 | endpoints: 6 | base_api: "https://apis-public-prod.tech.tvnz.co.nz" 7 | brightcove: "https://edge.api.brightcove.com/playback/v1/accounts/{}/videos/{}" 8 | 9 | policy: "BCpkADawqM0IurzupiJKMb49WkxM__ngDMJ3GOQBhN2ri2Ci_lHwDWIpf4sLFc8bANMc-AVGfGR8GJNgxGqXsbjP1gHsK2Fpkoj6BSpwjrKBnv1D5l5iGPvVYCo" -------------------------------------------------------------------------------- /unshackle/core/tracks/__init__.py: -------------------------------------------------------------------------------- 1 | from .attachment import Attachment 2 | from .audio import Audio 3 | from .chapter import Chapter 4 | from .chapters import Chapters 5 | from .hybrid import Hybrid 6 | from .subtitle import Subtitle 7 | from .track import Track 8 | from .tracks import Tracks 9 | from .video import Video 10 | 11 | __all__ = ("Audio", "Attachment", "Chapter", "Chapters", "Hybrid", "Subtitle", "Track", "Tracks", "Video") 12 | -------------------------------------------------------------------------------- /unshackle/services/CBS/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | user-agent: Mozilla/5.0 (Linux; Android 13; SM-A536E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36 3 | 4 | endpoints: 5 | base_url: https://cbsdigital.cbs.com 6 | token: ABBsaBMagMmYLUc9iXB0lXEKsUQ0/MwRn6z3Tg0KKQaH7Q6QGqJcABwlBP4XiMR1b0Q= 7 | 8 | assets: [HLS_AES, DASH_LIVE, DASH_CENC, DASH_CENC_HDR10, DASH_LIVE, DASH_TA, DASH_CENC_PS4] 9 | 10 | 11 | -------------------------------------------------------------------------------- /unshackle/services/CWTV/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | User-Agent: Mozilla/5.0 (Linux; Android 11; Smart TV Build/AR2101; wv) 3 | 4 | endpoints: 5 | base_url: https://images.cwtv.com 6 | playback: https://edge.api.brightcove.com/playback/v1/accounts/6415823816001/videos/{} 7 | 8 | policy_key: BCpkADawqM0t2qFXB_K2XdHv2JmeRgQjpP6De9_Fl7d4akhL5aeqYwErorzsAxa7dyOF2FdxuG5wWVOREHEwb0DI-M8CGBBDpqwvDBEPfDKQg7kYGnccdNDErkvEh2O28CrGR3sEG6MZBlZ03I0xH7EflYKooIhfwvNWWw 9 | -------------------------------------------------------------------------------- /unshackle/services/PLUTO/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | auth: https://boot.pluto.tv/v4/start 3 | search: https://service-media-search.clusters.pluto.tv/v1/search 4 | series: https://service-vod.clusters.pluto.tv/v3/vod/series/{season_id}/seasons 5 | episodes: http://api.pluto.tv/v2/episodes/{episode_id}/clips.json 6 | movie: https://service-vod.clusters.pluto.tv/v4/vod/items?ids={video_id} 7 | license: https://service-concierge.clusters.pluto.tv/v1/wv/alt -------------------------------------------------------------------------------- /unshackle/services/ARD/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | Accept-Language: de-DE,de;q=0.8 3 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0 4 | 5 | endpoints: 6 | item: https://api.ardmediathek.de/page-gateway/pages/ard/item/{item_id}?embedded=true&mcV6=true 7 | grouping: https://api.ardmediathek.de/page-gateway/pages/ard/grouping/{item_id}?seasoned=true&embedded=true 8 | hbbtv: https://tv.ardmediathek.de/dyn/get?id=video:{item_id} 9 | -------------------------------------------------------------------------------- /unshackle/services/EXAMPLE/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | login: https://api.domain.com/v1/login 3 | metadata: https://api.domain.com/v1/metadata/{title_id}.json 4 | streams: https://api.domain.com/v1/streams 5 | playready_license: https://api.domain.com/v1/license/playready 6 | widevine_license: https://api.domain.com/v1/license/widevine 7 | 8 | client: 9 | android_tv: 10 | user_agent: USER_AGENT 11 | license_user_agent: LICENSE_USER_AGENT 12 | type: DATA 13 | -------------------------------------------------------------------------------- /unshackle/services/UKTV/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | BCOV-POLICY: BCpkADawqM2ZEz-kf0i2xEP9VuhJF_DB5boH7YAeSx5EHDSNFFl4QUoHZ3bKLQ9yWboSOBNyvZKm4HiZrqMNRxXm-laTAnmls1QOL7_kUM3Eij4KjQMz0epMs3WIedg64fnRxQTX6XubGE9p 3 | User-Agent: Dalvik/2.1.0 (Linux; U; Android 12; SM-A226B Build/SP1A.210812.016) 4 | Host: edge.api.brightcove.com 5 | Connection: keep-alive 6 | 7 | endpoints: 8 | base: https://vschedules.uktv.co.uk/vod/ 9 | playback: https://edge.api.brightcove.com/playback/v1/accounts/1242911124001/videos/{id} 10 | -------------------------------------------------------------------------------- /unshackle/services/MAX/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | contentRoutes: 'https://default.prd.api.hbomax.com/cms/routes/%s/%s?include=default' 3 | moviePages: 'https://default.prd.api.hbomax.com/content/videos/%s/activeVideoForShow?&include=edit' 4 | playbackInfo: 'https://default.prd.api.hbomax.com/any/playback/v1/playbackInfo' 5 | showPages: 'https://default.prd.api.hbomax.com/cms/collections/generic-show-page-rail-episodes-tabbed-content?include=default&pf[show.id]=%s&%s' 6 | bootstrap: 'https://default.prd.api.hbomax.com/session-context/headwaiter/v1/bootstrap' 7 | -------------------------------------------------------------------------------- /unshackle/services/MTSP/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | Accept-Language: de-DE,de;q=0.8 3 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0 4 | 5 | endpoints: 6 | login_form: https://www.magentasport.de/service/auth/web/login?headto=https://www.magentasport.de/home 7 | login_post: https://accounts.login.idm.telekom.com/factorx 8 | video_config: https://www.magentasport.de/service/player/v2/videoConfig?videoid={video_id}&partnerid=0&language=de&format=iphone&device=desktop&platform=web&cdn=telekom_cdn&userType=loggedin-entitled 9 | -------------------------------------------------------------------------------- /unshackle/services/NRK/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | Accept-Language: nb-NO,de;q=0.8 3 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0 4 | 5 | endpoints: 6 | content: https://psapi.nrk.no/tv/catalog/programs/{content_id}?contentGroup=adults&ageRestriction=None 7 | metadata: https://psapi.nrk.no/playback/metadata/program/{content_id} 8 | # manifest: https://psapi.nrk.no/playback/manifest/program/{content_id} 9 | manifest: https://psapi.nrk.no/playback/manifest/program/{content_id}?eea-portability=true&ageRestriction=None 10 | -------------------------------------------------------------------------------- /unshackle/services/NPO/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | metadata: "https://npo.nl/start/_next/data/{build_id}/video/{slug}.json" 3 | metadata_series: "https://npo.nl/start/_next/data/{build_id}/serie/{slug}.json" 4 | metadata_episode: "https://npo.nl/start/_next/data/{build_id}/serie/{series_slug}/seizoen-{season_slug}/{episode_slug}.json" 5 | streams: "https://prod.npoplayer.nl/stream-link" 6 | player_token: "https://npo.nl/start/api/domain/player-token?productId={product_id}" 7 | widevine_license: "https://npo-drm-gateway.samgcloud.nepworldwide.nl/authentication" 8 | homepage: "https://npo.nl/start" 9 | -------------------------------------------------------------------------------- /unshackle/services/PLEX/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | User-Agent: Mozilla/5.0 (Linux; Android 11; Smart TV Build/AR2101; wv) 3 | 4 | endpoints: 5 | base_url: https://vod.provider.plex.tv 6 | user: https://plex.tv/api/v2/users/anonymous 7 | screen: https://luma.plex.tv/api/screen 8 | provider: provider://tv.plex.provider.vod 9 | manifest_clear: /library/parts/{}?includeAllStreams=1&X-Plex-Product=Plex+Mediaverse&X-Plex-Token={} 10 | manifest_drm: /library/parts/{}?includeAllStreams=1&X-Plex-Product=Plex+Mediaverse&X-Plex-Token={}&X-Plex-DRM=widevine 11 | license: /library/parts/{}/license?X-Plex-Token={}&X-Plex-DRM=widevine 12 | -------------------------------------------------------------------------------- /unshackle/services/SPOT/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | albums: https://api.spotify.com/v1/albums/{id} 3 | playlists: https://api.spotify.com/v1/playlists/{id} 4 | artists: https://api.spotify.com/v1/artists/{id}/top-tracks 5 | tracks: https://api.spotify.com/v1/tracks/{id} 6 | pssh: https://seektables.scdn.co/seektable/{file_id}.json 7 | metadata: https://spclient.wg.spotify.com/metadata/4/track/{gid}?market=from_token 8 | stream: https://gue1-spclient.spotify.com/storage-resolve/v2/files/audio/interactive/11/{file_id}?version=10000000&product=9&platform=39&alt=json 9 | license: https://gae2-spclient.spotify.com/widevine-license/v1/audio/license -------------------------------------------------------------------------------- /WVDs/device/metadata.yml: -------------------------------------------------------------------------------- 1 | capabilities: 2 | analog_output_capabilities: ANALOG_OUTPUT_UNKNOWN 3 | anti_rollback_usage_table: false 4 | can_update_srm: false 5 | max_hdcp_version: HDCP_NONE 6 | oem_crypto_api_version: 13 7 | session_token: true 8 | supported_certificate_key_type: 9 | - RSA_2048 10 | client_info: 11 | architecture_name: arm64-v8a 12 | build_info: NVIDIA/icosa/icosa:9/PPR1.180610.011/4199485_1739.5219:user/release-keys 13 | company_name: NINTENDO 14 | device_name: icosa 15 | model_name: Switch 16 | oem_crypto_security_patch_level: '0' 17 | product_name: icosa 18 | widevine_cdm_version: 14.0.0 19 | wvd: 20 | device_type: ANDROID 21 | security_level: 3 22 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | 4 | repos: 5 | - repo: https://github.com/compilerla/conventional-pre-commit 6 | rev: v4.2.0 7 | hooks: 8 | - id: conventional-pre-commit 9 | stages: [commit-msg] 10 | - repo: https://github.com/mtkennerly/pre-commit-hooks 11 | rev: v0.4.0 12 | hooks: 13 | - id: poetry-ruff-check 14 | - repo: https://github.com/pycqa/isort 15 | rev: 6.0.1 16 | hooks: 17 | - id: isort 18 | - repo: https://github.com/pre-commit/pre-commit-hooks 19 | rev: v5.0.0 20 | hooks: 21 | - id: end-of-file-fixer 22 | - id: trailing-whitespace 23 | args: [--markdown-linebreak-ext=md] 24 | -------------------------------------------------------------------------------- /unshackle/services/NF/MSL/schemes/__init__.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Scheme(Enum): 5 | def __str__(self): 6 | return str(self.value) 7 | 8 | 9 | class EntityAuthenticationSchemes(Scheme): 10 | """https://github.com/Netflix/msl/wiki/Entity-Authentication-%28Configuration%29""" 11 | Unauthenticated = "NONE" 12 | Widevine = "WIDEVINE" 13 | 14 | 15 | class UserAuthenticationSchemes(Scheme): 16 | """https://github.com/Netflix/msl/wiki/User-Authentication-%28Configuration%29""" 17 | EmailPassword = "EMAIL_PASSWORD" 18 | NetflixIDCookies = "NETFLIXID" 19 | 20 | 21 | class KeyExchangeSchemes(Scheme): 22 | """https://github.com/Netflix/msl/wiki/Key-Exchange-%28Configuration%29""" 23 | AsymmetricWrapped = "ASYMMETRIC_WRAPPED" 24 | Widevine = "WIDEVINE" 25 | -------------------------------------------------------------------------------- /unshackle/services/NBLA/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | Accept-Language: en-US,en;q=0.8 3 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0 4 | 5 | endpoints: 6 | login: https://nebula.tv/auth/login/ 7 | authorization: https://users.api.nebula.app/api/v1/authorization/ 8 | content: https://content.api.nebula.app/content/{slug}/ 9 | season: https://content.api.nebula.app/seasons/{id}/ 10 | video: https://content.api.nebula.app/content/videos/{slug}/ 11 | video_channel: https://content.api.nebula.app/video_channels/{id}/ 12 | video_channel_episodes: https://content.api.nebula.app/video_channels/{id}/video_episodes/?ordering=published_at 13 | manifest: https://content.api.nebula.app/video_episodes/{video_id}/manifest.m3u8?token={jwt}&app_version=25.2.1&platform=web 14 | -------------------------------------------------------------------------------- /unshackle/services/README.md: -------------------------------------------------------------------------------- 1 | A collection of non-premium services for Unshackle. 2 | 3 | ## Usage: 4 | Clone repository: 5 | 6 | `git clone https://github.com/stabbedbybrick/services.git` 7 | 8 | Add folder to `unshackle.yaml`: 9 | 10 | ``` 11 | directories: 12 | services: "path/to/services" 13 | ``` 14 | See help text for each service: 15 | 16 | `unshackle dl SERVICE --help` 17 | 18 | ## Notes: 19 | Some versions of the dependencies work better than others. These are the recommended versions as of 25/11/11: 20 | 21 | - Shaka Packager: [v2.6.1](https://github.com/shaka-project/shaka-packager/releases/tag/v2.6.1) 22 | - CCExtractor: [v0.93](https://github.com/CCExtractor/ccextractor/releases/tag/v0.93) 23 | - MKVToolNix: [latest](https://mkvtoolnix.download/downloads.html) 24 | - FFmpeg: [latest](https://ffmpeg.org/download.html) -------------------------------------------------------------------------------- /unshackle/utils/osenvironment.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | 4 | def get_os_arch(name: str) -> str: 5 | """Builds a name-os-arch based on the input name, system, architecture.""" 6 | os_name = platform.system().lower() 7 | os_arch = platform.machine().lower() 8 | 9 | # Map platform.system() output to desired OS name 10 | if os_name == "windows": 11 | os_name = "win" 12 | elif os_name == "darwin": 13 | os_name = "osx" 14 | else: 15 | os_name = "linux" 16 | 17 | # Map platform.machine() output to desired architecture 18 | if os_arch in ["x86_64", "amd64"]: 19 | os_arch = "x64" 20 | elif os_arch == "arm64": 21 | os_arch = "arm64" 22 | 23 | # Construct the dependency name in the desired format using the input name 24 | return f"{name}-{os_name}-{os_arch}" 25 | -------------------------------------------------------------------------------- /unshackle/core/utils/osenvironment.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | 4 | def get_os_arch(name: str) -> str: 5 | """Builds a name-os-arch based on the input name, system, architecture.""" 6 | os_name = platform.system().lower() 7 | os_arch = platform.machine().lower() 8 | 9 | # Map platform.system() output to desired OS name 10 | if os_name == "windows": 11 | os_name = "win" 12 | elif os_name == "darwin": 13 | os_name = "osx" 14 | else: 15 | os_name = "linux" 16 | 17 | # Map platform.machine() output to desired architecture 18 | if os_arch in ["x86_64", "amd64"]: 19 | os_arch = "x64" 20 | elif os_arch == "arm64": 21 | os_arch = "arm64" 22 | 23 | # Construct the dependency name in the desired format using the input name 24 | return f"{name}-{os_name}-{os_arch}" 25 | -------------------------------------------------------------------------------- /unshackle/services/TPTV/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0 3 | Accept: '*/*' 4 | Accept-Language: en-GBen;q=0.5 5 | Access-Control-Request-Method: POST 6 | Access-Control-Request-Headers: baggagecontent-typesentry-tracesessiontenant 7 | Referer: https://tptvencore.co.uk/ 8 | Origin: https://tptvencore.co.uk 9 | DNT: '1' 10 | Connection: keep-alive 11 | Sec-Fetch-Dest: empty 12 | Sec-Fetch-Mode: cors 13 | Sec-Fetch-Site: cross-site 14 | Priority: u=4 15 | 16 | session: 17 | api-key: zq5pyPd0RTbNg3Fyj52PrkKL9c2Af38HHh4itgZTKDaCzjAyhd 18 | 19 | endpoints: 20 | login: https://prod.suggestedtv.com/api/client/v1/session/login 21 | session: https://prod.suggestedtv.com/api/client/v1/session 22 | search: https://prod.suggestedtv.com/api/client/v2/search/{query} 23 | 24 | -------------------------------------------------------------------------------- /unshackle/core/utils/xml.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from lxml import etree 4 | from lxml.etree import ElementTree 5 | 6 | 7 | def load_xml(xml: Union[str, bytes]) -> ElementTree: 8 | """Safely parse XML data to an ElementTree, without namespaces in tags.""" 9 | if not isinstance(xml, bytes): 10 | xml = xml.encode("utf8") 11 | root = etree.fromstring(xml) 12 | for elem in root.getiterator(): 13 | if not hasattr(elem.tag, "find"): 14 | # e.g. comment elements 15 | continue 16 | elem.tag = etree.QName(elem).localname 17 | for name, value in elem.attrib.items(): 18 | local_name = etree.QName(name).localname 19 | if local_name == name: 20 | continue 21 | del elem.attrib[name] 22 | elem.attrib[local_name] = value 23 | etree.cleanup_namespaces(root) 24 | return root 25 | -------------------------------------------------------------------------------- /unshackle/core/utils/gen_esn.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import random 4 | from datetime import datetime, timedelta 5 | 6 | log = logging.getLogger("NF-ESN") 7 | 8 | 9 | def chrome_esn_generator(): 10 | ESN_GEN = "".join(random.choice("0123456789ABCDEF") for _ in range(30)) 11 | esn_file = ".esn" 12 | 13 | def gen_file(): 14 | with open(esn_file, "w") as file: 15 | file.write(f"NFCDIE-03-{ESN_GEN}") 16 | 17 | if not os.path.isfile(esn_file): 18 | log.warning("Generating a new Chrome ESN") 19 | gen_file() 20 | 21 | file_datetime = datetime.fromtimestamp(os.path.getmtime(esn_file)) 22 | time_diff = datetime.now() - file_datetime 23 | if time_diff > timedelta(hours=6): 24 | log.warning("Old ESN detected, Generating a new Chrome ESN") 25 | gen_file() 26 | 27 | with open(esn_file, "r") as f: 28 | esn = f.read() 29 | 30 | return esn 31 | -------------------------------------------------------------------------------- /unshackle/services/STV/config.yaml: -------------------------------------------------------------------------------- 1 | accounts: 2 | drm: "6204867266001" 3 | clear: "1486976045" 4 | 5 | headers: 6 | drm: 7 | BCOV-POLICY: BCpkADawqM32Q7lZg8ME0ydIOV8bD_9Ke2YD5wvY_T2Rq2TBtz6QQfpHtSAJTiDL-MiYAxyJVvScaKt82d1Q6b_wP6MG-O8SGQjRnwczfdsTesTZy-uj23uKv1vjHijtTeQC0DONN53zS38v 8 | User-Agent: Dalvik/2.1.0 (Linux; U; Android 12; SM-A226B Build/SP1A.210812.016) 9 | Host: edge.api.brightcove.com 10 | Connection: keep-alive 11 | clear: 12 | BCOV-POLICY: BCpkADawqM2Dpx-ht5hP1rQqWFTcOTqTT5x5bSUlY8FaOO1_P8LcKxmL2wrFzTvRb3HzO2YTIzVDuoeLfqvFvp1dWRPnxKT8zt9ErkENYteaU9T6lz7OogjL8W8 13 | User-Agent: Dalvik/2.1.0 (Linux; U; Android 12; SM-A226B Build/SP1A.210812.016) 14 | Host: edge.api.brightcove.com 15 | Connection: keep-alive 16 | 17 | endpoints: 18 | base: https://player.api.stv.tv/v1/ 19 | playback: https://edge.api.brightcove.com/playback/v1/accounts/{accounts}/videos/{id} 20 | search: https://api.swiftype.com/api/v1/public/engines/search.json 21 | -------------------------------------------------------------------------------- /unshackle/core/manifests/m3u8.py: -------------------------------------------------------------------------------- 1 | """Utility functions for parsing M3U8 playlists.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Optional, Union 6 | 7 | import m3u8 8 | from curl_cffi.requests import Session as CurlSession 9 | from requests import Session 10 | 11 | from unshackle.core.manifests.hls import HLS 12 | from unshackle.core.tracks import Tracks 13 | 14 | 15 | def parse( 16 | master: m3u8.M3U8, 17 | language: str, 18 | *, 19 | session: Optional[Union[Session, CurlSession]] = None, 20 | ) -> Tracks: 21 | """Parse a variant playlist to ``Tracks`` with basic information, defer DRM loading.""" 22 | tracks = HLS(master, session=session).to_tracks(language) 23 | 24 | bool(master.session_keys or HLS.parse_session_data_keys(master, session or Session())) 25 | 26 | if True: 27 | for t in tracks.videos + tracks.audio: 28 | t.needs_drm_loading = True 29 | t.session = session 30 | 31 | return tracks 32 | 33 | 34 | __all__ = ["parse"] 35 | -------------------------------------------------------------------------------- /unshackle/core/utils/subprocess.py: -------------------------------------------------------------------------------- 1 | import json 2 | import subprocess 3 | from pathlib import Path 4 | from typing import Union 5 | 6 | from unshackle.core import binaries 7 | 8 | 9 | def ffprobe(uri: Union[bytes, Path]) -> dict: 10 | """Use ffprobe on the provided data to get stream information.""" 11 | if not binaries.FFProbe: 12 | raise EnvironmentError('FFProbe executable "ffprobe" not found but is required.') 13 | 14 | args = [binaries.FFProbe, "-v", "quiet", "-of", "json", "-show_streams"] 15 | if isinstance(uri, Path): 16 | args.extend( 17 | ["-f", "lavfi", "-i", "movie={}[out+subcc]".format(str(uri).replace("\\", "/").replace(":", "\\\\:"))] 18 | ) 19 | elif isinstance(uri, bytes): 20 | args.append("pipe:") 21 | try: 22 | ff = subprocess.run(args, input=uri if isinstance(uri, bytes) else None, check=True, capture_output=True) 23 | except subprocess.CalledProcessError: 24 | return {} 25 | return json.loads(ff.stdout.decode("utf8")) 26 | -------------------------------------------------------------------------------- /unshackle/services/DSNP/config.yaml: -------------------------------------------------------------------------------- 1 | CLIENT_ID: disney-svod-3d9324fc 2 | CLIENT_VERSION: "9.10.0" 3 | 4 | API_KEY: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA" 5 | CONFIG_URL: https://bam-sdk-configs.bamgrid.com/bam-sdk/v5.0/disney-svod-3d9324fc/android/v9.10.0/google/tv/prod.json 6 | 7 | PAGE_SIZE_SETS: 15 8 | PAGE_SIZE_CONTENT: 30 9 | SEARCH_QUERY_TYPE: ge 10 | BAM_PARTNER: disney 11 | EXPLORE_VERSION: v1.3 12 | DEVICE_FAMILY: browser 13 | DEVICE_PROFILE: tv 14 | APPLICATION_RUNTIME: android 15 | 16 | HEADERS: 17 | User-Agent: BAMSDK/v9.10.0 (disney-svod-3d9324fc 2.26.2-rc1.0; v5.0/v9.10.0; android; tv) 18 | x-application-version: google 19 | x-bamsdk-platform-id: android-tv 20 | x-bamsdk-client-id: disney-svod-3d9324fc 21 | x-bamsdk-platform: android-tv 22 | x-bamsdk-version: "9.10.0" 23 | Accept-Encoding: gzip 24 | Origin: https://www.disneyplus.com 25 | 26 | LICENSE: https://disney.playback.edge.bamgrid.com/widevine/v1/obtain-license 27 | PLAYREADY_LICENSE: https://disney.playback.edge.bamgrid.com/playready/v1/obtain-license.asmx 28 | -------------------------------------------------------------------------------- /unshackle/services/ALL4/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | Accept-Language: en-US,en;q=0.8 3 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36 4 | 5 | endpoints: 6 | login: https://api.channel4.com/online/v2/auth/token 7 | title: https://api.channel4.com/online/v1/views/content-hubs/{title}.json 8 | license: https://c4.eme.lp.aws.redbeemedia.com/wvlicenceproxy-service/widevine/acquire 9 | search: https://all4nav.channel4.com/v1/api/search 10 | 11 | android: 12 | key: QVlESUQ4U0RGQlA0TThESA==" 13 | iv: MURDRDAzODNES0RGU0w4Mg==" 14 | auth: MzZVVUN0OThWTVF2QkFnUTI3QXU4ekdIbDMxTjlMUTE6Sllzd3lIdkdlNjJWbGlrVw== 15 | vod: https://api.channel4.com/online/v1/vod/stream/{video_id}?client=android-mod 16 | 17 | web: 18 | key: bjljTGllWWtxd3pOQ3F2aQ== 19 | iv: b2R6Y1UzV2RVaVhMdWNWZA== 20 | vod: https://www.channel4.com/vod/stream/{programmeId} 21 | 22 | device: 23 | platform_name: android 24 | device_type: mobile 25 | device_name: "Sony C6903 (C6903)" 26 | app_version: "android_app:9.4.2" 27 | optimizely_datafile: "2908" 28 | -------------------------------------------------------------------------------- /unshackle/core/proxies/proxy.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Optional 3 | 4 | 5 | class Proxy: 6 | @abstractmethod 7 | def __init__(self, **kwargs): 8 | """ 9 | The constructor initializes the Service using passed configuration data. 10 | 11 | Any authorization or pre-fetching of data should be done here. 12 | """ 13 | 14 | @abstractmethod 15 | def __repr__(self) -> str: 16 | """Return a string denoting a list of Countries and Servers (if possible).""" 17 | countries = ... 18 | servers = ... 19 | return f"{countries} Countr{['ies', 'y'][countries == 1]} ({servers} Server{['s', ''][servers == 1]})" 20 | 21 | @abstractmethod 22 | def get_proxy(self, query: str) -> Optional[str]: 23 | """ 24 | Get a Proxy URI from the Proxy Service. 25 | 26 | Only return None if the query was accepted, but no proxy could be returned. 27 | Otherwise, please use exceptions to denote any errors with the call or query. 28 | 29 | The returned Proxy URI must be a string supported by Python-Requests: 30 | '{scheme}://[{user}:{pass}@]{host}:{port}' 31 | """ 32 | -------------------------------------------------------------------------------- /unshackle/services/PCOK/config.yaml: -------------------------------------------------------------------------------- 1 | endpoints: 2 | stream_tv: 'https://www.peacocktv.com/stream-tv/{title_id}' 3 | config: 'https://config.clients.peacocktv.com/{territory}/{provider}/{proposition}/{device}/PROD/{version}/config.json' 4 | login: 'https://rango.id.peacocktv.com/signin/service/international' 5 | personas: 'https://persona.id.peacocktv.com/persona-store/personas' 6 | tokens: 'https://ovp.peacocktv.com/auth/tokens' 7 | me: 'https://ovp.peacocktv.com/auth/users/me' 8 | node: 'https://atom.peacocktv.com/adapter-calypso/v3/query/node' 9 | vod: 'https://ovp.peacocktv.com/video/playouts/vod' 10 | 11 | client: 12 | config_version: '1.0.8' 13 | territory: 'US' 14 | provider: 'NBCU' 15 | proposition: 'NBCUOTT' 16 | platform: 'ANDROID' # PC, ANDROID 17 | device: 'TABLET' # COMPUTER, TABLET 18 | id: 'Jcvf1y0whKOI29vRXcJy' 19 | drm_device_id: 'UNKNOWN' 20 | client_sdk: 'NBCU-WEB-v4' # NBCU-ANDROID-v3 NBCU-ANDRTV-v4 21 | auth_scheme: 'MESSO' 22 | auth_issuer: 'NOWTV' 23 | 24 | security: 25 | signature_hmac_key_v4: 'FvT9VtwvhtSZvqnExMsvDDTEvBqR3HdsMcBFtWYV' 26 | signature_hmac_key_v6: 'izU6EJqqu6DOhOWSk5X4p9dod3fNqH7vzKtYDK8d' 27 | signature_format: 'SkyOTT client="{client}",signature="{signature}",timestamp="{timestamp}",version="1.0"' 28 | -------------------------------------------------------------------------------- /unshackle/core/constants.py: -------------------------------------------------------------------------------- 1 | from threading import Event 2 | from typing import TypeVar, Union 3 | 4 | DOWNLOAD_CANCELLED = Event() 5 | DOWNLOAD_LICENCE_ONLY = Event() 6 | 7 | DRM_SORT_MAP = ["ClearKey", "Widevine"] 8 | LANGUAGE_MAX_DISTANCE = 5 # this is max to be considered "same", e.g., en, en-US, en-AU 9 | LANGUAGE_EXACT_DISTANCE = 0 # exact match only, no variants 10 | VIDEO_CODEC_MAP = {"AVC": "H.264", "HEVC": "H.265"} 11 | DYNAMIC_RANGE_MAP = { 12 | "HDR10": "HDR", 13 | "HDR10+": "HDR10P", 14 | "Dolby Vision": "DV", 15 | "HDR10 / HDR10+": "HDR10P", 16 | "HDR10 / HDR10": "HDR", 17 | } 18 | AUDIO_CODEC_MAP = {"E-AC-3": "DDP", "AC-3": "DD"} 19 | 20 | context_settings = dict( 21 | help_option_names=["-?", "-h", "--help"], # default only has --help 22 | max_content_width=116, # max PEP8 line-width, -4 to adjust for initial indent 23 | ) 24 | 25 | # For use in signatures of functions which take one specific type of track at a time 26 | # (it can't be a list that contains e.g. both Video and Audio objects) 27 | TrackT = TypeVar("TrackT", bound="Track") # noqa: F821 28 | 29 | # For general use in lists that can contain mixed types of tracks. 30 | # list[Track] won't work because list is invariant. 31 | # TODO: Add Chapter? 32 | AnyTrack = Union["Video", "Audio", "Subtitle"] # noqa: F821 33 | -------------------------------------------------------------------------------- /unshackle/core/commands.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import click 4 | 5 | from unshackle.core.config import config 6 | from unshackle.core.utilities import import_module_by_path 7 | 8 | _COMMANDS = sorted( 9 | (path for path in config.directories.commands.glob("*.py") if path.stem.lower() != "__init__"), key=lambda x: x.stem 10 | ) 11 | 12 | _MODULES = {path.stem: getattr(import_module_by_path(path), path.stem) for path in _COMMANDS} 13 | 14 | 15 | class Commands(click.MultiCommand): 16 | """Lazy-loaded command group of project commands.""" 17 | 18 | def list_commands(self, ctx: click.Context) -> list[str]: 19 | """Returns a list of command names from the command filenames.""" 20 | return [x.stem for x in _COMMANDS] 21 | 22 | def get_command(self, ctx: click.Context, name: str) -> Optional[click.Command]: 23 | """Load the command code and return the main click command function.""" 24 | module = _MODULES.get(name) 25 | if not module: 26 | raise click.ClickException(f"Unable to find command by the name '{name}'") 27 | 28 | if hasattr(module, "cli"): 29 | return module.cli 30 | 31 | return module 32 | 33 | 34 | # Hide direct access to commands from quick import form, they shouldn't be accessed directly 35 | __all__ = ("Commands",) 36 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | This project is managed using [UV](https://github.com/astral-sh/uv), a fast Python package and project manager. 4 | Install the latest version of UV before continuing. Development currently requires Python 3.9+. 5 | 6 | ## Set up 7 | 8 | Starting from Zero? Not sure where to begin? Here's steps on setting up this Python project using UV. Note that 9 | UV installation instructions should be followed from the UV Docs: https://docs.astral.sh/uv/getting-started/installation/ 10 | 11 | 1. Clone the Repository: 12 | 13 | ```shell 14 | git clone https://github.com/unshackle-dl/unshackle 15 | cd unshackle 16 | ``` 17 | 18 | 2. Install the Project with UV: 19 | 20 | ```shell 21 | uv sync 22 | ``` 23 | 24 | This creates a Virtual environment and then installs all project dependencies and executables into the Virtual 25 | environment. Your System Python environment is not affected at all. 26 | 27 | 3. Run commands in the Virtual environment: 28 | 29 | ```shell 30 | uv run unshackle 31 | ``` 32 | 33 | Note: 34 | 35 | - UV automatically manages the virtual environment for you - no need to manually activate it 36 | - You can use `uv run` to prefix any command you wish to run under the Virtual environment 37 | - For example: `uv run unshackle --help` to run the main application 38 | - JetBrains PyCharm and Visual Studio Code will automatically detect the UV-managed virtual environment 39 | - For more information, see: https://docs.astral.sh/uv/concepts/projects/ 40 | 41 | 4. Install Pre-commit tooling to ensure safe and quality commits: 42 | 43 | ```shell 44 | uv run pre-commit install 45 | ``` 46 | -------------------------------------------------------------------------------- /install.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal EnableExtensions EnableDelayedExpansion 3 | 4 | echo. 5 | echo === Unshackle setup (Windows) === 6 | echo. 7 | 8 | where uv >nul 2>&1 9 | if %errorlevel% equ 0 ( 10 | echo [OK] uv is already installed. 11 | goto install_deps 12 | ) 13 | 14 | echo [..] uv not found. Installing... 15 | 16 | powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://astral.sh/uv/install.ps1 | iex" 17 | if %errorlevel% neq 0 ( 18 | echo [ERR] Failed to install uv. 19 | echo PowerShell may be blocking scripts. Try: 20 | echo Set-ExecutionPolicy RemoteSigned -Scope CurrentUser 21 | echo or install manually: https://docs.astral.sh/uv/getting-started/installation/ 22 | pause 23 | exit /b 1 24 | ) 25 | 26 | set "UV_BIN=" 27 | for %%D in ("%USERPROFILE%\.local\bin" "%LOCALAPPDATA%\Programs\uv\bin" "%USERPROFILE%\.cargo\bin") do ( 28 | if exist "%%~fD\uv.exe" set "UV_BIN=%%~fD" 29 | ) 30 | 31 | if not defined UV_BIN ( 32 | echo [WARN] Could not locate uv.exe. You may need to reopen your terminal. 33 | ) else ( 34 | set "PATH=%UV_BIN%;%PATH%" 35 | ) 36 | 37 | :: Verify 38 | uv --version >nul 2>&1 39 | if %errorlevel% neq 0 ( 40 | echo [ERR] uv still not reachable in this shell. Open a new terminal and re-run this script. 41 | pause 42 | exit /b 1 43 | ) 44 | echo [OK] uv installed and reachable. 45 | 46 | :install_deps 47 | echo. 48 | uv sync 49 | if %errorlevel% neq 0 ( 50 | echo [ERR] Dependency install failed. See errors above. 51 | pause 52 | exit /b 1 53 | ) 54 | 55 | echo. 56 | echo Installation completed successfully! 57 | echo Try: 58 | echo uv run unshackle --help 59 | echo. 60 | pause 61 | endlocal 62 | -------------------------------------------------------------------------------- /unshackle/core/utils/collections.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from typing import Any, Iterable, Iterator, Sequence, Tuple, Type, Union 3 | 4 | 5 | def as_lists(*args: Any) -> Iterator[Any]: 6 | """Converts any input objects to list objects.""" 7 | for item in args: 8 | yield item if isinstance(item, list) else [item] 9 | 10 | 11 | def as_list(*args: Any) -> list: 12 | """ 13 | Convert any input objects to a single merged list object. 14 | 15 | Example: 16 | >>> as_list('foo', ['buzz', 'bizz'], 'bazz', 'bozz', ['bar'], ['bur']) 17 | ['foo', 'buzz', 'bizz', 'bazz', 'bozz', 'bar', 'bur'] 18 | """ 19 | return list(itertools.chain.from_iterable(as_lists(*args))) 20 | 21 | 22 | def flatten(items: Any, ignore_types: Union[Type, Tuple[Type, ...]] = str) -> Iterator: 23 | """ 24 | Flattens items recursively. 25 | 26 | Example: 27 | >>> list(flatten(["foo", [["bar", ["buzz", [""]], "bee"]]])) 28 | ['foo', 'bar', 'buzz', '', 'bee'] 29 | >>> list(flatten("foo")) 30 | ['foo'] 31 | >>> list(flatten({1}, set)) 32 | [{1}] 33 | """ 34 | if isinstance(items, (Iterable, Sequence)) and not isinstance(items, ignore_types): 35 | for i in items: 36 | yield from flatten(i, ignore_types) 37 | else: 38 | yield items 39 | 40 | 41 | def merge_dict(source: dict, destination: dict) -> None: 42 | """Recursively merge Source into Destination in-place.""" 43 | if not source: 44 | return 45 | for key, value in source.items(): 46 | if isinstance(value, dict): 47 | # get node or create one 48 | node = destination.setdefault(key, {}) 49 | merge_dict(value, node) 50 | else: 51 | destination[key] = value 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: Sp5rky 7 | --- 8 | 9 | **Feature Category** 10 | What area does this feature request relate to? 11 | 12 | - [ ] Core framework (downloaders, DRM, track handling) 13 | - [ ] CLI/commands (new commands or command improvements) 14 | - [ ] Configuration system 15 | - [ ] Manifest parsing (DASH, HLS, ISM) 16 | - [ ] Output/muxing (naming, metadata, tagging) 17 | - [ ] Proxy system 18 | - [ ] Key vault system 19 | - [ ] Documentation 20 | - [ ] Other (please specify) 21 | 22 | **Is your feature request related to a problem? Please describe.** 23 | A clear and concise description of what the problem is. 24 | Example: "I'm always frustrated when [...]" 25 | 26 | **Describe the solution you'd like** 27 | A clear and concise description of what you want to happen. 28 | 29 | **Describe alternatives you've considered** 30 | A clear and concise description of any alternative solutions or features you've considered. 31 | 32 | **Reference implementations** (if applicable) 33 | Have you seen this feature in other tools? 34 | 35 | - [ ] Vinetrimmer 36 | - [ ] yt-dlp 37 | - [ ] Other: [please specify] 38 | 39 | Please describe how it works there (without sharing proprietary code). 40 | 41 | **Use case / Impact** 42 | 43 | - How would this feature benefit users? 44 | - How often would you use this feature? 45 | - Does this solve a common workflow issue? 46 | 47 | **Additional context** 48 | Add any other context or screenshots about the feature request here. 49 | 50 | --- 51 | 52 | **⚠️ Note:** 53 | This project focuses on the core framework and tooling. Service-specific feature requests should focus on what the framework should support, not specific service implementations. 54 | -------------------------------------------------------------------------------- /post_processing/post_processing.md: -------------------------------------------------------------------------------- 1 | # Post Processing. 2 | 3 | With a mega program like envied it is sometimes difficult to make internal program changes to reflect personal needs. 4 | 5 | Using a post-processor on the downloaded results may provide a solution. 6 | 7 | I have found two unmet needs: 8 | 9 | * Extract srt subtitles 10 | * Convert mkv output to mp4 (or any other video container format, with code adjustment) 11 | 12 | Here are two scripts which operate from a root folder (downloads - for instance), and operate on all the mkv files found within. The original files are left in place - for you to remove as necessary. 13 | 14 | ## Extract srt subtitles 15 | 16 | Use 17 | * uv run envied dl -S my5 https://www.channel5.com/show/taggart/season-1/killer 18 | 19 | -S tells envied to download subtitles only. However it produces an mks file as a result. 20 | 21 | Run 'python extract_mks_subs.py' in the root folder with your mks downloaded files. There are options: --dry-run will allow checking all is well before extraction. 22 | 23 | If you start with a full mkv container - video, audio and subtitles tracks - and wish to extract subtitles, then they will no longer be held in track 0 of an mks file - the program defaults. So use the --track parameter and set it to the third track of 0,1,2. And the full container extension is mkv -the script needs the default settings over-riding with: 24 | 25 | * 'python extract_mks_subs.py --track 2 --ext mkv '. 26 | 27 | 28 | ## Convert to mp4 29 | 30 | Run 31 | * 'python mkv_to_mp4.py' 32 | 33 | in the root folder with mkv files. 34 | 35 | The option: --dry-run will allow checking all is well before converion. 36 | 37 | Conversion to other formats is complex and not suited to this simple routine as the audio/video codecs would each need need re-coding to suit the required output. -------------------------------------------------------------------------------- /unshackle/services/CR/config.yaml: -------------------------------------------------------------------------------- 1 | # Crunchyroll API Configuration 2 | client: 3 | id: "lkesi7snsy9oojmi2r9h" 4 | secret: "-aGDXFFNTluZMLYXERngNYnEjvgH5odv" 5 | 6 | # API Endpoints 7 | endpoints: 8 | # Authentication 9 | token: "https://www.crunchyroll.com/auth/v1/token" 10 | 11 | # Account 12 | account_me: "https://www.crunchyroll.com/accounts/v1/me" 13 | multiprofile: "https://www.crunchyroll.com/accounts/v1/{account_id}/multiprofile" 14 | 15 | # Content Metadata 16 | series: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}" 17 | seasons: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}/seasons" 18 | season_episodes: "https://www.crunchyroll.com/content/v2/cms/seasons/{season_id}/episodes" 19 | skip_events: "https://static.crunchyroll.com/skip-events/production/{episode_id}.json" 20 | 21 | # Playback 22 | playback: "https://www.crunchyroll.com/playback/v2/{episode_id}/tv/android_tv/play" 23 | playback_delete: "https://www.crunchyroll.com/playback/v1/token/{episode_id}/{token}" 24 | playback_sessions: "https://www.crunchyroll.com/playback/v1/sessions/streaming" 25 | license_widevine: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/widevine" 26 | 27 | # Discovery 28 | search: "https://www.crunchyroll.com/content/v2/discover/search" 29 | 30 | # Headers for Android TV client 31 | headers: 32 | user-agent: "Crunchyroll/ANDROIDTV/3.49.1_22281 (Android 11; en-US; SHIELD Android TV)" 33 | accept: "application/json" 34 | accept-charset: "UTF-8" 35 | accept-encoding: "gzip" 36 | connection: "Keep-Alive" 37 | content-type: "application/x-www-form-urlencoded; charset=UTF-8" 38 | 39 | # Query parameters 40 | params: 41 | locale: "en-US" 42 | 43 | # Device parameters for authentication 44 | device: 45 | type: "ANDROIDTV" 46 | name: "SHIELD Android TV" 47 | model: "SHIELD Android TV" 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: Sp5rky 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Run command `uv run unshackle [...]` 16 | 2. See error 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **System Details** 22 | 23 | - OS: [e.g. Windows 11, Ubuntu 22.04, macOS 14] 24 | - unshackle Version: [e.g. 1.0.1] 25 | 26 | **Dependency Versions** (if relevant) 27 | 28 | - Shaka-packager: [e.g. 2.6.1] 29 | - n_m3u8dl-re: [e.g. 0.3.0-beta] 30 | - aria2c: [e.g. 1.36.0] 31 | - ffmpeg: [e.g. 6.0] 32 | - Other: [e.g. ccextractor, subby] 33 | 34 | **Logs/Error Output** 35 | 36 |
37 | Click to expand logs 38 | 39 | ``` 40 | Paste relevant error messages or stack traces here 41 | ``` 42 | 43 |
44 | 45 | **Configuration** (if relevant) 46 | Please describe relevant configuration settings (DO NOT paste credentials or API keys): 47 | 48 | - Downloader used: [e.g. requests, aria2c, n_m3u8dl_re] 49 | - Proxy provider: [e.g. NordVPN, none] 50 | - Other relevant config options 51 | 52 | **Screenshots** 53 | If applicable, add screenshots to help explain your problem. 54 | 55 | **Additional context** 56 | Add any other context about the problem here. 57 | 58 | --- 59 | 60 | **⚠️ Important:** 61 | 62 | - **DO NOT include service-specific implementation code** unless you have explicit rights to share it 63 | - **DO NOT share credentials, API keys, WVD files, or authentication tokens** 64 | - For service-specific issues, describe the behavior without revealing proprietary implementation details 65 | - Focus on core framework issues (downloads, DRM, track handling, CLI, configuration, etc.) 66 | -------------------------------------------------------------------------------- /unshackle/core/search_result.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | 4 | class SearchResult: 5 | def __init__( 6 | self, 7 | id_: Union[str, int], 8 | title: str, 9 | description: Optional[str] = None, 10 | label: Optional[str] = None, 11 | url: Optional[str] = None, 12 | ): 13 | """ 14 | A Search Result for any support Title Type. 15 | 16 | Parameters: 17 | id_: The search result's Title ID. 18 | title: The primary display text, e.g., the Title's Name. 19 | description: The secondary display text, e.g., the Title's Description or 20 | further title information. 21 | label: The tertiary display text. This will typically be used to display 22 | an informative label or tag to the result. E.g., "unavailable", the 23 | title's price tag, region, etc. 24 | url: A hyperlink to the search result or title's page. 25 | """ 26 | if not isinstance(id_, (str, int)): 27 | raise TypeError(f"Expected id_ to be a {str} or {int}, not {type(id_)}") 28 | if not isinstance(title, str): 29 | raise TypeError(f"Expected title to be a {str}, not {type(title)}") 30 | if not isinstance(description, (str, type(None))): 31 | raise TypeError(f"Expected description to be a {str}, not {type(description)}") 32 | if not isinstance(label, (str, type(None))): 33 | raise TypeError(f"Expected label to be a {str}, not {type(label)}") 34 | if not isinstance(url, (str, type(None))): 35 | raise TypeError(f"Expected url to be a {str}, not {type(url)}") 36 | 37 | self.id = id_ 38 | self.title = title 39 | self.description = description 40 | self.label = label 41 | self.url = url 42 | 43 | 44 | __all__ = ("SearchResult",) 45 | -------------------------------------------------------------------------------- /unshackle/core/vault.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from typing import Iterator, Optional, Union 3 | from uuid import UUID 4 | 5 | 6 | class Vault(metaclass=ABCMeta): 7 | def __init__(self, name: str, no_push: bool = False): 8 | self.name = name 9 | self.no_push = no_push 10 | 11 | def __str__(self) -> str: 12 | return f"{self.name} {type(self).__name__}" 13 | 14 | @abstractmethod 15 | def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]: 16 | """ 17 | Get Key from Vault by KID (Key ID) and Service. 18 | 19 | It does not get Key by PSSH as the PSSH can be different depending on it's implementation, 20 | or even how it was crafted. Some PSSH values may also actually be a CENC Header rather 21 | than a PSSH MP4 Box too, which makes the value even more confusingly different. 22 | 23 | However, the KID never changes unless the video file itself has changed too, meaning the 24 | key for the presumed-matching KID wouldn't work, further proving matching by KID is 25 | superior. 26 | """ 27 | 28 | @abstractmethod 29 | def get_keys(self, service: str) -> Iterator[tuple[str, str]]: 30 | """Get All Keys from Vault by Service.""" 31 | 32 | @abstractmethod 33 | def add_key(self, service: str, kid: Union[UUID, str], key: str) -> bool: 34 | """Add KID:KEY to the Vault.""" 35 | 36 | @abstractmethod 37 | def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int: 38 | """ 39 | Add Multiple Content Keys with Key IDs for Service to the Vault. 40 | Pre-existing Content Keys are ignored/skipped. 41 | Raises PermissionError if the user has no permission to create the table. 42 | """ 43 | 44 | @abstractmethod 45 | def get_services(self) -> Iterator[str]: 46 | """Get a list of Service Tags from Vault.""" 47 | 48 | 49 | __all__ = ("Vault",) 50 | -------------------------------------------------------------------------------- /unshackle/core/proxies/basic.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | from typing import Optional, Union 4 | 5 | from requests.utils import prepend_scheme_if_needed 6 | from urllib3.util import parse_url 7 | 8 | from unshackle.core.proxies.proxy import Proxy 9 | 10 | 11 | class Basic(Proxy): 12 | def __init__(self, **countries: dict[str, Union[str, list[str]]]): 13 | """Basic Proxy Service using Proxies specified in the config.""" 14 | self.countries = {k.lower(): v for k, v in countries.items()} 15 | 16 | def __repr__(self) -> str: 17 | countries = len(self.countries) 18 | servers = len(self.countries.values()) 19 | 20 | return f"{countries} Countr{['ies', 'y'][countries == 1]} ({servers} Server{['s', ''][servers == 1]})" 21 | 22 | def get_proxy(self, query: str) -> Optional[str]: 23 | """Get a proxy URI from the config.""" 24 | query = query.lower() 25 | 26 | match = re.match(r"^([a-z]{2})(\d+)?$", query, re.IGNORECASE) 27 | if not match: 28 | raise ValueError(f'The query "{query}" was not recognized...') 29 | 30 | country_code = match.group(1) 31 | entry = match.group(2) 32 | 33 | servers: Optional[Union[str, list[str]]] = self.countries.get(country_code) 34 | if not servers: 35 | return None 36 | 37 | if isinstance(servers, str): 38 | proxy = servers 39 | elif entry: 40 | try: 41 | proxy = servers[int(entry) - 1] 42 | except IndexError: 43 | raise ValueError( 44 | f'There\'s only {len(servers)} prox{"y" if len(servers) == 1 else "ies"} for "{country_code}"...' 45 | ) 46 | else: 47 | proxy = random.choice(servers) 48 | 49 | proxy = prepend_scheme_if_needed(proxy, "http") 50 | parsed_proxy = parse_url(proxy) 51 | if not parsed_proxy.host: 52 | raise ValueError(f"The proxy '{proxy}' is not a valid proxy URI supported by Python-Requests.") 53 | 54 | return proxy 55 | -------------------------------------------------------------------------------- /unshackle/core/binaries.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import sys 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | __shaka_platform = {"win32": "win", "darwin": "osx"}.get(sys.platform, sys.platform) 7 | 8 | 9 | def find(*names: str) -> Optional[Path]: 10 | """Find the path of the first found binary name.""" 11 | current_dir = Path(__file__).resolve().parent.parent 12 | local_binaries_dir = current_dir / "binaries" 13 | 14 | ext = ".exe" if sys.platform == "win32" else "" 15 | 16 | for name in names: 17 | if local_binaries_dir.exists(): 18 | candidate_paths = [local_binaries_dir / f"{name}{ext}", local_binaries_dir / name / f"{name}{ext}"] 19 | 20 | for path in candidate_paths: 21 | if path.is_file(): 22 | # On Unix-like systems, check if file is executable 23 | if sys.platform == "win32" or (path.stat().st_mode & 0o111): 24 | return path 25 | 26 | # Fall back to system PATH 27 | path = shutil.which(name) 28 | if path: 29 | return Path(path) 30 | return None 31 | 32 | 33 | FFMPEG = find("ffmpeg") 34 | FFProbe = find("ffprobe") 35 | FFPlay = find("ffplay") 36 | SubtitleEdit = find("SubtitleEdit") 37 | ShakaPackager = find( 38 | "shaka-packager", 39 | "packager", 40 | f"packager-{__shaka_platform}", 41 | f"packager-{__shaka_platform}-arm64", 42 | f"packager-{__shaka_platform}-x64", 43 | ) 44 | Aria2 = find("aria2c", "aria2") 45 | CCExtractor = find("ccextractor", "ccextractorwin", "ccextractorwinfull") 46 | HolaProxy = find("hola-proxy") 47 | MPV = find("mpv") 48 | Caddy = find("caddy") 49 | N_m3u8DL_RE = find("N_m3u8DL-RE", "n-m3u8dl-re") 50 | MKVToolNix = find("mkvmerge") 51 | Mkvpropedit = find("mkvpropedit") 52 | DoviTool = find("dovi_tool") 53 | HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool") 54 | Mp4decrypt = find("mp4decrypt") 55 | 56 | 57 | __all__ = ( 58 | "FFMPEG", 59 | "FFProbe", 60 | "FFPlay", 61 | "SubtitleEdit", 62 | "ShakaPackager", 63 | "Aria2", 64 | "CCExtractor", 65 | "HolaProxy", 66 | "MPV", 67 | "Caddy", 68 | "N_m3u8DL_RE", 69 | "MKVToolNix", 70 | "Mkvpropedit", 71 | "DoviTool", 72 | "HDR10PlusTool", 73 | "Mp4decrypt", 74 | "find", 75 | ) 76 | -------------------------------------------------------------------------------- /unshackle/core/proxies/hola.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | import subprocess 4 | from typing import Optional 5 | 6 | from unshackle.core import binaries 7 | from unshackle.core.proxies.proxy import Proxy 8 | 9 | 10 | class Hola(Proxy): 11 | def __init__(self): 12 | """ 13 | Proxy Service using Hola's direct connections via the hola-proxy project. 14 | https://github.com/Snawoot/hola-proxy 15 | """ 16 | self.binary = binaries.HolaProxy 17 | if not self.binary: 18 | raise EnvironmentError("hola-proxy executable not found but is required for the Hola proxy provider.") 19 | 20 | self.countries = self.get_countries() 21 | 22 | def __repr__(self) -> str: 23 | countries = len(self.countries) 24 | 25 | return f"{countries} Countr{['ies', 'y'][countries == 1]}" 26 | 27 | def get_proxy(self, query: str) -> Optional[str]: 28 | """ 29 | Get an HTTP proxy URI for a Datacenter ('direct') or Residential ('lum') Hola server. 30 | 31 | TODO: - Add ability to select 'lum' proxies (residential proxies). 32 | - Return and use Proxy Authorization 33 | """ 34 | query = query.lower() 35 | 36 | p = subprocess.check_output( 37 | [self.binary, "-country", query, "-list-proxies"], stderr=subprocess.STDOUT 38 | ).decode() 39 | 40 | if "Transaction error: temporary ban detected." in p: 41 | raise ConnectionError("Hola banned your IP temporarily from it's services. Try change your IP.") 42 | 43 | username, password, proxy_authorization = re.search( 44 | r"Login: (.*)\nPassword: (.*)\nProxy-Authorization: (.*)", p 45 | ).groups() 46 | 47 | servers = re.findall(r"(zagent.*)", p) 48 | proxies = [] 49 | for server in servers: 50 | host, ip_address, direct, peer, hola, trial, trial_peer, vendor = server.split(",") 51 | proxies.append(f"http://{username}:{password}@{ip_address}:{peer}") 52 | 53 | proxy = random.choice(proxies) 54 | return proxy 55 | 56 | def get_countries(self) -> list[dict[str, str]]: 57 | """Get a list of available Countries.""" 58 | p = subprocess.check_output([self.binary, "-list-countries"]).decode("utf8") 59 | 60 | return [{code: name} for country in p.splitlines() for (code, name) in [country.split(" - ", maxsplit=1)]] 61 | -------------------------------------------------------------------------------- /unshackle/core/__main__.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import logging 3 | 4 | import click 5 | import urllib3 6 | from rich import traceback 7 | from rich.console import Group 8 | from rich.padding import Padding 9 | from rich.text import Text 10 | from urllib3.exceptions import InsecureRequestWarning 11 | 12 | from unshackle.core import __version__ 13 | from unshackle.core.commands import Commands 14 | from unshackle.core.config import config 15 | from unshackle.core.console import ComfyRichHandler, console 16 | from unshackle.core.constants import context_settings 17 | from unshackle.core.update_checker import UpdateChecker 18 | from unshackle.core.utilities import close_debug_logger, init_debug_logger 19 | 20 | 21 | @click.command(cls=Commands, invoke_without_command=True, context_settings=context_settings) 22 | @click.option("-v", "--version", is_flag=True, default=False, help="Print version information.") 23 | @click.option("-d", "--debug", is_flag=True, default=False, help="Enable DEBUG level logs and JSON debug logging.") 24 | def main(version: bool, debug: bool) -> None: 25 | """unshackle—Modular Movie, TV, and Music Archival Software.""" 26 | debug_logging_enabled = debug or config.debug 27 | 28 | logging.basicConfig( 29 | level=logging.DEBUG if debug else logging.INFO, 30 | format="%(message)s", 31 | handlers=[ 32 | ComfyRichHandler( 33 | show_time=False, 34 | show_path=debug, 35 | console=console, 36 | rich_tracebacks=True, 37 | tracebacks_suppress=[click], 38 | log_renderer=console._log_render, # noqa 39 | ) 40 | ], 41 | ) 42 | 43 | if debug_logging_enabled: 44 | init_debug_logger(enabled=True) 45 | 46 | urllib3.disable_warnings(InsecureRequestWarning) 47 | 48 | traceback.install(console=console, width=80, suppress=[click]) 49 | 50 | console.print( 51 | Padding( 52 | Group( 53 | Text( 54 | r"░█▀▀░█▀█░█░█░▀█▀░█▀▀░█▀▄" + "\n" 55 | r"░█▀▀░█░█░▀▄▀░░█░░█▀▀░█░█" + "\n" 56 | r"░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░▀▀░" + "\n" , 57 | style="ascii.art", 58 | ), 59 | Text(" and more than unshackled...", style = "ascii.art"), 60 | f"\nv [repr.number]{__version__}[/] - https://github.com/vinefeeder/envied", 61 | ), 62 | (1, 11, 1, 10), 63 | expand=True, 64 | ), 65 | justify="center", 66 | ) 67 | 68 | 69 | @atexit.register 70 | def cleanup(): 71 | """Clean up resources on exit.""" 72 | close_debug_logger() 73 | 74 | 75 | if __name__ == "__main__": 76 | main() 77 | -------------------------------------------------------------------------------- /unshackle/services/NF/MSL/schemes/UserAuthentication.py: -------------------------------------------------------------------------------- 1 | from ..MSLObject import MSLObject 2 | from . import UserAuthenticationSchemes 3 | 4 | 5 | # noinspection PyPep8Naming 6 | class UserAuthentication(MSLObject): 7 | def __init__(self, scheme, authdata): 8 | """ 9 | Data used to identify and authenticate the user associated with a message. 10 | https://github.com/Netflix/msl/wiki/User-Authentication-%28Configuration%29 11 | 12 | :param scheme: User Authentication Scheme identifier 13 | :param authdata: User Authentication data 14 | """ 15 | self.scheme = str(scheme) 16 | self.authdata = authdata 17 | 18 | @classmethod 19 | def EmailPassword(cls, email, password): 20 | """ 21 | Email and password is a standard user authentication scheme in wide use. 22 | 23 | :param email: user email address 24 | :param password: user password 25 | """ 26 | return cls( 27 | scheme=UserAuthenticationSchemes.EmailPassword, 28 | authdata={ 29 | "email": email, 30 | "password": password 31 | } 32 | ) 33 | 34 | @classmethod 35 | def NetflixIDCookies(cls, netflixid, securenetflixid): 36 | """ 37 | Netflix ID HTTP cookies are used when the user has previously logged in to a web site. Possession of the 38 | cookies serves as proof of user identity, in the same manner as they do when communicating with the web site. 39 | 40 | The Netflix ID cookie and Secure Netflix ID cookie are HTTP cookies issued by the Netflix web site after 41 | subscriber login. The Netflix ID cookie is encrypted and identifies the subscriber and analogous to a 42 | subscriber’s username. The Secure Netflix ID cookie is tied to a Netflix ID cookie and only sent over HTTPS 43 | and analogous to a subscriber’s password. 44 | 45 | In some cases the Netflix ID and Secure Netflix ID cookies will be unavailable to the MSL stack or application. 46 | If either or both of the Netflix ID or Secure Netflix ID cookies are absent in the above data structure the 47 | HTTP cookie headers will be queried for it; this is only acceptable when HTTPS is used as the underlying 48 | transport protocol. 49 | 50 | :param netflixid: Netflix ID cookie 51 | :param securenetflixid: Secure Netflix ID cookie 52 | """ 53 | return cls( 54 | scheme=UserAuthenticationSchemes.NetflixIDCookies, 55 | authdata={ 56 | "netflixid": netflixid, 57 | "securenetflixid": securenetflixid 58 | } 59 | ) 60 | -------------------------------------------------------------------------------- /Install-media-tools.sh: -------------------------------------------------------------------------------- 1 | # Thanks Akirainblack for providing this routine 2 | # run script as superuser using command:- 3 | # sudo bash Install-media-tools.sh 4 | 5 | # Install MKVToolNix and ffmpeg from Ubuntu repos 6 | apt-get update && \ 7 | apt-get install -y mkvtoolnix mkvtoolnix-gui ffmpeg && \ 8 | rm -rf /var/lib/apt/lists/* 9 | 10 | # Install Bento4 (mp4decrypt) 11 | wget https://www.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-641.x86_64-unknown-linux.zip && \ 12 | unzip -j Bento4-SDK-1-6-0-641.x86_64-unknown-linux.zip \ 13 | 'Bento4-SDK-1-6-0-641.x86_64-unknown-linux/bin/*' -d /usr/local/bin/ && \ 14 | rm Bento4-SDK-1-6-0-641.x86_64-unknown-linux.zip && \ 15 | chmod +x /usr/local/bin/* 16 | 17 | # Install N_m3u8DL-RE 18 | wget https://github.com/nilaoda/N_m3u8DL-RE/releases/download/v0.3.0-beta/N_m3u8DL-RE_v0.3.0-beta_linux-x64_20241203.tar.gz && \ 19 | tar -xzf N_m3u8DL-RE_v0.3.0-beta_linux-x64_20241203.tar.gz && \ 20 | find . -name "N_m3u8DL-RE" -type f -exec mv {} /usr/local/bin/ \; && \ 21 | chmod +x /usr/local/bin/N_m3u8DL-RE && \ 22 | rm -rf N_m3u8DL-RE_v0.3.0-beta_linux-x64_20241203.tar.gz 23 | 24 | # Install Shaka Packager 25 | wget https://github.com/shaka-project/shaka-packager/releases/download/v3.2.0/packager-linux-x64 && \ 26 | mv packager-linux-x64 /usr/local/bin/shaka-packager && \ 27 | chmod +x /usr/local/bin/shaka-packager 28 | 29 | # Install dovi_tool 30 | wget https://github.com/quietvoid/dovi_tool/releases/download/2.3.1/dovi_tool-2.3.1-x86_64-unknown-linux-musl.tar.gz && \ 31 | tar -xzf dovi_tool-2.3.1-x86_64-unknown-linux-musl.tar.gz && \ 32 | find . -name "dovi_tool" -type f -exec mv {} /usr/local/bin/ \; && \ 33 | chmod +x /usr/local/bin/dovi_tool && \ 34 | rm -rf dovi_tool-2.3.1-x86_64-unknown-linux-musl.tar.gz 35 | 36 | # Install HDR10Plus 37 | wget https://github.com/quietvoid/hdr10plus_tool/releases/download/1.7.1/hdr10plus_tool-1.7.1-x86_64-unknown-linux-musl.tar.gz && \ 38 | tar -xzf hdr10plus_tool-1.7.1-x86_64-unknown-linux-musl.tar.gz && \ 39 | find . -name "hdr10plus_tool" -type f -exec mv {} /usr/local/bin/ \; && \ 40 | chmod +x /usr/local/bin/hdr10plus_tool && \ 41 | rm -rf hdr10plus_tool-1.7.1-x86_64-unknown-linux-musl.tar.gz 42 | 43 | # Install SubtitleEdit 44 | wget https://github.com/SubtitleEdit/subtitleedit/releases/download/4.0.14/SE4014.zip && \ 45 | unzip SE4014.zip -d /SE && \ 46 | awk 'BEGIN{print "#!/bin/bash"}' >> /usr/local/bin/SubtitleEdit && \ 47 | echo "exec mono /SE/SubtitleEdit.exe \"\$@\"" >> /usr/local/bin/SubtitleEdit && \ 48 | chmod +x /usr/local/bin/SubtitleEdit && \ 49 | rm -rf SE4014.zip 50 | 51 | # Install uv by copying from official image 52 | # cp --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 53 | python -m pip install uv 54 | -------------------------------------------------------------------------------- /unshackle/core/titles/title.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import abstractmethod 4 | from typing import Any, Optional, Union 5 | 6 | from langcodes import Language 7 | from pymediainfo import MediaInfo 8 | 9 | from unshackle.core.tracks import Tracks 10 | 11 | 12 | class Title: 13 | def __init__( 14 | self, id_: Any, service: type, language: Optional[Union[str, Language]] = None, data: Optional[Any] = None 15 | ) -> None: 16 | """ 17 | Media Title from a Service. 18 | 19 | Parameters: 20 | id_: An identifier for this specific title. It must be unique. Can be of any 21 | value. 22 | service: Service class that this title is from. 23 | language: The original recorded language for the title. If that information 24 | is not available, this should not be set to anything. 25 | data: Arbitrary storage for the title. Often used to store extra metadata 26 | information, IDs, URIs, and so on. 27 | """ 28 | if not id_: # includes 0, false, and similar values, this is intended 29 | raise ValueError("A unique ID must be provided") 30 | if hasattr(id_, "__len__") and len(id_) < 4: 31 | raise ValueError("The unique ID is not large enough, clash likely.") 32 | 33 | if not service: 34 | raise ValueError("Service class must be provided") 35 | if not isinstance(service, type): 36 | raise TypeError(f"Expected service to be a Class (type), not {service!r}") 37 | 38 | if language is not None: 39 | if isinstance(language, str): 40 | language = Language.get(language) 41 | elif not isinstance(language, Language): 42 | raise TypeError(f"Expected language to be a {Language} or str, not {language!r}") 43 | 44 | self.id = id_ 45 | self.service = service 46 | self.language = language 47 | self.data = data 48 | 49 | self.tracks = Tracks() 50 | 51 | def __eq__(self, other: Title) -> bool: 52 | return self.id == other.id 53 | 54 | @abstractmethod 55 | def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: 56 | """ 57 | Get a Filename for this Title with the provided Media Info. 58 | All filenames should be sanitized with the sanitize_filename() utility function. 59 | 60 | Parameters: 61 | media_info: MediaInfo object of the file this name will be used for. 62 | folder: This filename will be used as a folder name. Some changes may want to 63 | be made if this is the case. 64 | show_service: Show the service tag (e.g., iT, NF) in the filename. 65 | """ 66 | 67 | 68 | __all__ = ("Title",) 69 | -------------------------------------------------------------------------------- /unshackle/services/NF/MSL/schemes/EntityAuthentication.py: -------------------------------------------------------------------------------- 1 | from .. import EntityAuthenticationSchemes 2 | from ..MSLObject import MSLObject 3 | 4 | 5 | # noinspection PyPep8Naming 6 | class EntityAuthentication(MSLObject): 7 | def __init__(self, scheme, authdata): 8 | """ 9 | Data used to identify and authenticate the entity associated with a message. 10 | https://github.com/Netflix/msl/wiki/Entity-Authentication-%28Configuration%29 11 | 12 | :param scheme: Entity Authentication Scheme identifier 13 | :param authdata: Entity Authentication data 14 | """ 15 | self.scheme = str(scheme) 16 | self.authdata = authdata 17 | 18 | @classmethod 19 | def Unauthenticated(cls, identity): 20 | """ 21 | The unauthenticated entity authentication scheme does not provide encryption or authentication and only 22 | identifies the entity. Therefore entity identities can be harvested and spoofed. The benefit of this 23 | authentication scheme is that the entity has control over its identity. This may be useful if the identity is 24 | derived from or related to other data, or if retaining the identity is desired across state resets or in the 25 | event of MSL errors requiring entity re-authentication. 26 | """ 27 | return cls( 28 | scheme=EntityAuthenticationSchemes.Unauthenticated, 29 | authdata={"identity": identity} 30 | ) 31 | 32 | @classmethod 33 | def Widevine(cls, devtype, keyrequest): 34 | """ 35 | The Widevine entity authentication scheme is used by devices with the Widevine CDM. It does not provide 36 | encryption or authentication and only identifies the entity. Therefore entity identities can be harvested 37 | and spoofed. The entity identity is composed from the provided device type and Widevine key request data. The 38 | Widevine CDM properties can be extracted from the key request data. 39 | 40 | When coupled with the Widevine key exchange scheme, the entity identity can be cryptographically validated by 41 | comparing the entity authentication key request data against the key exchange key request data. 42 | 43 | Note that the local entity will not know its entity identity when using this scheme. 44 | 45 | > Devtype 46 | 47 | An arbitrary value identifying the device type the local entity wishes to assume. The data inside the Widevine 48 | key request may be optionally used to validate the claimed device type. 49 | 50 | :param devtype: Local entity device type 51 | :param keyrequest: Widevine key request 52 | """ 53 | return cls( 54 | scheme=EntityAuthenticationSchemes.Widevine, 55 | authdata={ 56 | "devtype": devtype, 57 | "keyrequest": keyrequest 58 | } 59 | ) 60 | -------------------------------------------------------------------------------- /unshackle/core/events.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from copy import deepcopy 4 | from enum import Enum 5 | from typing import Any, Callable 6 | 7 | 8 | class Events: 9 | class Types(Enum): 10 | _reserved = 0 11 | # A Track's segment has finished downloading 12 | SEGMENT_DOWNLOADED = 1 13 | # Track has finished downloading 14 | TRACK_DOWNLOADED = 2 15 | # Track has finished decrypting 16 | TRACK_DECRYPTED = 3 17 | # Track has finished repacking 18 | TRACK_REPACKED = 4 19 | # Track is about to be Multiplexed into a Container 20 | TRACK_MULTIPLEX = 5 21 | 22 | def __init__(self): 23 | self.__subscriptions: dict[Events.Types, list[Callable]] = {} 24 | self.__ephemeral: dict[Events.Types, list[Callable]] = {} 25 | self.reset() 26 | 27 | def reset(self): 28 | """Reset Event Observer clearing all Subscriptions.""" 29 | self.__subscriptions = {k: [] for k in Events.Types.__members__.values()} 30 | self.__ephemeral = deepcopy(self.__subscriptions) 31 | 32 | def subscribe(self, event_type: Events.Types, callback: Callable, ephemeral: bool = False) -> None: 33 | """ 34 | Subscribe to an Event with a Callback. 35 | 36 | Parameters: 37 | event_type: The Events.Type to subscribe to. 38 | callback: The function or lambda to call on event emit. 39 | ephemeral: Unsubscribe the callback from the event on first emit. 40 | Note that this is not thread-safe and may be called multiple 41 | times at roughly the same time. 42 | """ 43 | [self.__subscriptions, self.__ephemeral][ephemeral][event_type].append(callback) 44 | 45 | def unsubscribe(self, event_type: Events.Types, callback: Callable) -> None: 46 | """ 47 | Unsubscribe a Callback from an Event. 48 | 49 | Parameters: 50 | event_type: The Events.Type to unsubscribe from. 51 | callback: The function or lambda to remove from event emit. 52 | """ 53 | if callback in self.__subscriptions[event_type]: 54 | self.__subscriptions[event_type].remove(callback) 55 | if callback in self.__ephemeral[event_type]: 56 | self.__ephemeral[event_type].remove(callback) 57 | 58 | def emit(self, event_type: Events.Types, *args: Any, **kwargs: Any) -> None: 59 | """ 60 | Emit an Event, executing all subscribed Callbacks. 61 | 62 | Parameters: 63 | event_type: The Events.Type to emit. 64 | args: Positional arguments to pass to callbacks. 65 | kwargs: Keyword arguments to pass to callbacks. 66 | """ 67 | if event_type not in self.__subscriptions: 68 | raise ValueError(f'Event type "{event_type}" is invalid') 69 | 70 | for callback in self.__subscriptions[event_type] + self.__ephemeral[event_type]: 71 | callback(*args, **kwargs) 72 | 73 | self.__ephemeral[event_type].clear() 74 | 75 | 76 | events = Events() 77 | -------------------------------------------------------------------------------- /unshackle/core/tracks/chapter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import Optional, Union 5 | from zlib import crc32 6 | 7 | TIMESTAMP_FORMAT = re.compile(r"^(?P\d{2}):(?P\d{2}):(?P\d{2})(?P.\d{3}|)$") 8 | 9 | 10 | class Chapter: 11 | def __init__(self, timestamp: Union[str, int, float], name: Optional[str] = None): 12 | """ 13 | Create a new Chapter with a Timestamp and optional name. 14 | 15 | The timestamp may be in the following formats: 16 | - "HH:MM:SS" string, e.g., `25:05:23`. 17 | - "HH:MM:SS.mss" string, e.g., `25:05:23.120`. 18 | - a timecode integer in milliseconds, e.g., `90323120` is `25:05:23.120`. 19 | - a timecode float in seconds, e.g., `90323.12` is `25:05:23.120`. 20 | 21 | If you have a timecode integer in seconds, just multiply it by 1000. 22 | If you have a timecode float in milliseconds (no decimal value), just convert 23 | it to an integer. 24 | """ 25 | if timestamp is None: 26 | raise ValueError("The timestamp must be provided.") 27 | 28 | if not isinstance(timestamp, (str, int, float)): 29 | raise TypeError(f"Expected timestamp to be {str}, {int} or {float}, not {type(timestamp)}") 30 | if not isinstance(name, (str, type(None))): 31 | raise TypeError(f"Expected name to be {str}, not {type(name)}") 32 | 33 | if not isinstance(timestamp, str): 34 | if isinstance(timestamp, int): # ms 35 | hours, remainder = divmod(timestamp, 1000 * 60 * 60) 36 | minutes, remainder = divmod(remainder, 1000 * 60) 37 | seconds, ms = divmod(remainder, 1000) 38 | elif isinstance(timestamp, float): # seconds.ms 39 | hours, remainder = divmod(timestamp, 60 * 60) 40 | minutes, remainder = divmod(remainder, 60) 41 | seconds, ms = divmod(int(remainder * 1000), 1000) 42 | else: 43 | raise TypeError 44 | timestamp = f"{int(hours):02}:{int(minutes):02}:{int(seconds):02}.{str(ms).zfill(3)[:3]}" 45 | 46 | timestamp_m = TIMESTAMP_FORMAT.match(timestamp) 47 | if not timestamp_m: 48 | raise ValueError(f"The timestamp format is invalid: {timestamp}") 49 | 50 | hour, minute, second, ms = timestamp_m.groups() 51 | if not ms: 52 | timestamp += ".000" 53 | 54 | self.timestamp = timestamp 55 | self.name = name 56 | 57 | def __repr__(self) -> str: 58 | return "{name}({items})".format( 59 | name=self.__class__.__name__, items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()]) 60 | ) 61 | 62 | def __str__(self) -> str: 63 | return " | ".join(filter(bool, ["CHP", self.timestamp, self.name])) 64 | 65 | @property 66 | def id(self) -> str: 67 | """Compute an ID from the Chapter data.""" 68 | checksum = crc32(str(self).encode("utf8")) 69 | return hex(checksum) 70 | 71 | @property 72 | def named(self) -> bool: 73 | """Check if Chapter is named.""" 74 | return bool(self.name) 75 | 76 | 77 | __all__ = ("Chapter",) 78 | -------------------------------------------------------------------------------- /unshackle/services/RKTN/config.yaml: -------------------------------------------------------------------------------- 1 | certificate: | 2 | CAUSxwUKwQIIAxIQFwW5F8wSBIaLBjM6L3cqjBiCtIKSBSKOAjCCAQoCggEBAJntWzsyfateJO/DtiqVtZhSCtW8yzdQPgZFuBTYdrjfQFEEQa2M462xG 3 | 7iMTnJaXkqeB5UpHVhYQCOn4a8OOKkSeTkwCGELbxWMh4x+Ib/7/up34QGeHleB6KRfRiY9FOYOgFioYHrc4E+shFexN6jWfM3rM3BdmDoh+07svUoQyk 4 | dJDKR+ql1DghjduvHK3jOS8T1v+2RC/THhv0CwxgTRxLpMlSCkv5fuvWCSmvzu9Vu69WTi0Ods18Vcc6CCuZYSC4NZ7c4kcHCCaA1vZ8bYLErF8xNEkKd 5 | O7DevSy8BDFnoKEPiWC8La59dsPxebt9k+9MItHEbzxJQAZyfWgkCAwEAAToUbGljZW5zZS53aWRldmluZS5jb20SgAOuNHMUtag1KX8nE4j7e7jLUnfS 6 | SYI83dHaMLkzOVEes8y96gS5RLknwSE0bv296snUE5F+bsF2oQQ4RgpQO8GVK5uk5M4PxL/CCpgIqq9L/NGcHc/N9XTMrCjRtBBBbPneiAQwHL2zNMr80 7 | NQJeEI6ZC5UYT3wr8+WykqSSdhV5Cs6cD7xdn9qm9Nta/gr52u/DLpP3lnSq8x2/rZCR7hcQx+8pSJmthn8NpeVQ/ypy727+voOGlXnVaPHvOZV+WRvWC 8 | q5z3CqCLl5+Gf2Ogsrf9s2LFvE7NVV2FvKqcWTw4PIV9Sdqrd+QLeFHd/SSZiAjjWyWOddeOrAyhb3BHMEwg2T7eTo/xxvF+YkPj89qPwXCYcOxF+6gjo 9 | mPwzvofcJOxkJkoMmMzcFBDopvab5tDQsyN9UPLGhGC98X/8z8QSQ+spbJTYLdgFenFoGq47gLwDS6NWYYQSqzE3Udf2W7pzk4ybyG4PHBYV3s4cyzdq8amvtE/sNSdOKReuHpfQ= 10 | 11 | gdpr_consent: | 12 | CPGeIEAPV65UAADABBNLCGCsAP_AAH_AAAAAHrsXZCpcBSlgYCpoAIoAKIAUEAAAgyAAABAAAoABCAAAIAQAgAAgIAAAAAAAAAAAIAJAAQAAAAEAAAAAAA 13 | AAAAAIIACAAAAAIABAAAAAAAAACAAAAAAAAAAAAAAEAAAAgABAABAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAgZ8xdkKlwFKWBgKGgAigAogBQQAACDIAAA 14 | EAACAAAIAAAgBACAACAAAAAAAAAAAAAgAgABAAAAAQAAAAAAAAAAAAggAAAAAAAgAEAAAAAAAAAAAAAAAAAAAAAAAAQAAACAAEAAEAAAAAAQAA.YAAAAAAAA8DA 15 | 16 | endpoints: 17 | title: https://gizmo.rakuten.tv/v3/movies/{title_id}? 18 | show: https://gizmo.rakuten.tv/v3/seasons/{title_id}? 19 | manifest: https://gizmo.rakuten.tv/v3/{kind}/streamings? 20 | auth: https://gizmo.rakuten.tv/v3/me/login_or_wuaki_link 21 | clients: 22 | web: 23 | app_version: v3.0.11 24 | device_identifier: web 25 | device_serial: 6cc3584a-c182-4cc1-9f8d-b90e4ed76de9 26 | player: web:DASH-CENC:WVM 27 | device_os: Windows 10 28 | device_model: GENERIC 29 | device_year: 2019 30 | device_brand: chrome 31 | device_sdk: 100.0.4896 32 | android: 33 | app_version: 3.22.0 34 | device_identifier: android 35 | device_serial: 3187ad6c-4d1c-4cbb-9c59-8396d054eb2a 36 | player: android:DASH-CENC 37 | device_os: Android 38 | device_model: SM-A105FN 39 | device_year: 2021 40 | device_brand: Samsung 41 | device_sdk: "" 42 | atvui40: 43 | app_version: v2.77.0 44 | device_identifier: atvui40 45 | device_serial: 0424814603535001d1b1 46 | player: atvui40:DASH-CENC:WVM 47 | device_os: Android TV UI 40 48 | device_model: SHIELD Android TV 49 | device_year: 1970 50 | device_brand: NVIDIA 51 | device_sdk: "" 52 | lgui40: 53 | app_version: v2.77.0 54 | device_identifier: lgui40 55 | device_serial: 203WRMD8U920 56 | player: lgui40:DASH-CENC:PR 57 | device_os: LG UI 40 58 | device_model: OLED65C11LB 59 | device_year: 2021 60 | device_brand: LG 61 | device_sdk: "" 62 | smui40: 63 | app_version: v2.77.0 64 | device_identifier: smui40 65 | device_serial: 6cc3584a-c182-4cc1-9f8d-b90e4ed76de9 66 | player: smtvui40:DASH-CENC:WVM 67 | device_os: Samsung UI 40 68 | device_model: QE43Q60RATXXH 69 | device_year: 2019 70 | device_brand: Samsung 71 | device_sdk: "" 72 | -------------------------------------------------------------------------------- /unshackle/services/MY5/config.yaml: -------------------------------------------------------------------------------- 1 | user_agent: Dalvik/2.1.0 (Linux; U; Android 14; SM-S901B Build/UP1A.231005.007) 2 | 3 | endpoints: 4 | base: https://corona.channel5.com 5 | content: https://corona.channel5.com/shows/{show}.json?platform=my5android 6 | episodes: https://corona.channel5.com/shows/{show}/episodes.json?platform=my5android 7 | single: https://corona.channel5.com/shows/{show}/seasons/{season}/episodes/{episode}.json?platform=my5android 8 | auth: https://cassie-auth.channel5.com/api/v2/media/my5androidhydradash/{title_id}.json 9 | search: https://corona.channel5.com/shows/search.json 10 | 11 | certificate: | 12 | LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlDdXpDQ0FpU2dBd0lCQWdJRVhMU1BGVEFOQmdrcWhraUc5dzBCQVFVRkFE 13 | QmxNUXN3Q1FZRFZRUUdFd0pIDQpRakVWTUJNR0ExVUVCd3dNUkdWbVlYVnNkQ0JEYVhSNU1SSXdFQVlEVlFRS0RBbERhR0Z1Ym1W 14 | c0lEVXhEekFODQpCZ05WQkFzTUJrTmhjM05wWlRFYU1CZ0dBMVVFQXd3UlEyRnpjMmxsSUUxbFpHbGhJRUYxZEdnd0hoY05NVGt3 15 | DQpOREUxTVRRd016QXhXaGNOTWprd05ERTFNVFF3TXpBeFdqQ0JqakVMTUFrR0ExVUVCaE1DUjBJeEVqQVFCZ05WDQpCQW9NQ1VO 16 | b1lXNXVaV3dnTlRFWE1CVUdBMVVFQ3d3T1EyRnpjMmxsSUdOc2FXVnVkSE14VWpCUUJnTlZCQU1NDQpTVU5oYzNOcFpTQlRaV3ht 17 | TFhOcFoyNWxaQ0JEWlhKMGFXWnBZMkYwWlNCbWIzSWdUWGsxSUVGdVpISnZhV1FnDQpUbVY0ZENCSFpXNGdZMnhwWlc1MElERTFO 18 | VFV6TXpZNU9ERXdnWjh3RFFZSktvWklodmNOQVFFQkJRQURnWTBBDQpNSUdKQW9HQkFNbVVTSHFCZ3pwbThXelVHZ2VDSWZvSTI3 19 | QlovQmNmWktpbnl5dXFNVlpDNXRLaUtaRWpydFV4DQpoMXFVcDJSSkN3Ui9RcENPQ2RQdFhzMENzekZvd1ByTlY4RHFtUXZqbzY5 20 | dlhvTEM3c2RLUjQ1cEFUQU8vY3JLDQorTUFPUXo1VWEyQ1ZrYnY1SCtaMVhWWndqbm1qNGJHZEJHM005b0NzQlVqTEh0bm1nQSty 21 | QWdNQkFBR2pUakJNDQpNQjBHQTFVZERnUVdCQlNVVUhrY3JKNUVkVTVWM2ZJbXQra1ljdkdnZFRBTEJnTlZIUThFQkFNQ0E3Z3dD 22 | UVlEDQpWUjBUQkFJd0FEQVRCZ05WSFNVRUREQUtCZ2dyQmdFRkJRY0RBakFOQmdrcWhraUc5dzBCQVFVRkFBT0JnUUFpDQpHNi84 23 | OUFEaDhEOUs0OXZjeklMQ2pqbGh6bG5US09GM2l1Um0vSjZYaWtxY3RxSDF0a01na0FXcHAwQldBRm9IDQpJbU5WSEtKdTRnZXgy 24 | cEtLejNqOVlRNG5EWENQVTdVb0N2aDl5TTNYT0RITWZRT01sZkRtMU9GZkh2QkJvSHNVDQpHSE9EQTkwQi8xcU0xSlFaZzBOVjZi 25 | UllrUytCOWdtSFI4dXhtZktrL0E9PQ0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ0KLS0tLS1CRUdJTiBQUklWQVRFIEtFWS0t 26 | LS0tDQpNSUlDZHdJQkFEQU5CZ2txaGtpRzl3MEJBUUVGQUFTQ0FtRXdnZ0pkQWdFQUFvR0JBTW1VU0hxQmd6cG04V3pVDQpHZ2VD 27 | SWZvSTI3QlovQmNmWktpbnl5dXFNVlpDNXRLaUtaRWpydFV4aDFxVXAyUkpDd1IvUXBDT0NkUHRYczBDDQpzekZvd1ByTlY4RHFt 28 | UXZqbzY5dlhvTEM3c2RLUjQ1cEFUQU8vY3JLK01BT1F6NVVhMkNWa2J2NUgrWjFYVlp3DQpqbm1qNGJHZEJHM005b0NzQlVqTEh0 29 | bm1nQStyQWdNQkFBRUNnWUFjTVY4SnN6OTFWWnlDaWcreDZTTnpZdlhHDQo3bTd4bFBSeEdqYXlQclZ6eVJ1YmJnNitPKzFoNS9G 30 | MFc4SWxwb21oOFdLUDhTMnl0RXBFQmhLbDRHN001WXdqDQp0SCtCVXFNMTNjbFdiQkxuQTZMT2RVeEVDTVhIUktjdHk5UE52UlJQ 31 | cU9aV0YycDc5U1BFdFY5Q2o1SXNaVUdNDQpRcHYybk5oN1M2MUZGRVRuSVFKQkFPTXJNd2tnOGQzbksyS0lnVUNrcEtCRHlGTUJj 32 | UXN0NG82VkxvVjNjenBwDQpxMW5FWGx4WnduMFh6Ni9GVjRWdTZYTjJLLzQxL2pCeWdTUlFXa05YVThNQ1FRRGpLYXVpdE1UajBM 33 | ajU3QkJ3DQppNkNON0VFeUJSSkZaVGRSMDM4ZzkxSEFoUkVXVWpuQ0Vrc1UwcTl4TUNOdnM3OFN4RmQ1ODg5RUJQTnd1RDdvDQor 34 | NTM1QWtFQTNwVTNYbHh2WUhQZktKNkR0cWtidlFSdFJoZUZnZVNsdGZzcUtCQVFVVTIwWFRKeEdwL0FWdjE3DQp1OGZxcDQwekpM 35 | VEhDa0F4SFpzME9qYVpHcDU0TFFKQWJtM01iUjA1ZFpINnlpdlMxaE5hYW9QR01iMjdZeGJRDQpMS3dHNmd5d3BrbEp4RE1XdHR4 36 | VHVYeXVJdlVHMVA5cFRJTThEeUhSeVR3cTU4bjVjeU1XYVFKQkFMVFRwZkVtDQoxdWhCeUd0NEtab3dYM2dhREpVZGU0ZjBwN3Ry 37 | RFZGcExDNVJYcVVBQXNBQ2pzTHNYaEFadlovUEEwUDBiU2hmDQp4cUFRa2lnYmNKRXdxdjQ9DQotLS0tLUVORCBQUklWQVRFIEtF 38 | WS0tLS0t -------------------------------------------------------------------------------- /unshackle/services/ZDF/config.yaml: -------------------------------------------------------------------------------- 1 | headers: 2 | Accept-Language: de-DE,de;q=0.8 3 | User-Agent: Mozilla/5.0 (Web0S; Linux/SmartTV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.79 Safari/537.36 DMOST/2.0.0 (; LGE; webOSTV; WEBOS6.3.2 03.34.95; W6_lm21a;) 4 | 5 | endpoints: 6 | graphql: https://api.zdf.de/graphql 7 | ptmd_base: https://api.zdf.de 8 | 9 | meta: 10 | # Known options: 11 | # ngplayer_2_5 (Web player - H.264 1080p + VP9 1080p)) 12 | # smarttv_6 (HBBTV - H.264 1080p + H.265 HLG 2160p) 13 | # smarttv_7 (Unknown - same formats as smarttv_6) 14 | # android_native_5 (Android - H.264 1080p + VP9 1080p + H.265 HLG 2160p) 15 | player_types: 16 | - android_native_5 17 | 18 | queries: 19 | VideoByCanonical: | 20 | query VideoByCanonical($canonical: String!, $first: Int) { 21 | videoByCanonical(canonical: $canonical) { 22 | id 23 | canonical 24 | contentType 25 | title 26 | editorialDate 27 | streamingOptions { 28 | ad 29 | ut 30 | dgs 31 | ov 32 | ks 33 | fsk 34 | } 35 | episodeInfo { 36 | episodeNumber 37 | seasonNumber 38 | } 39 | structuralMetadata { 40 | isChildrenContent 41 | publicationFormInfo { 42 | original 43 | transformed 44 | } 45 | visualDimension { 46 | moods(first: $first) { 47 | nodes { 48 | mood 49 | } 50 | } 51 | } 52 | } 53 | smartCollection { 54 | id 55 | canonical 56 | title 57 | collectionType 58 | structuralMetadata { 59 | contentFamily 60 | publicationFormInfo { 61 | original 62 | transformed 63 | } 64 | } 65 | } 66 | seo { 67 | title 68 | } 69 | availability { 70 | fskBlocked 71 | } 72 | currentMediaType 73 | subtitle 74 | webUrl 75 | publicationDate 76 | currentMedia { 77 | nodes { 78 | ptmdTemplate 79 | ... on VodMedia { 80 | duration 81 | aspectRatio 82 | visible 83 | geoLocation 84 | highestVerticalResolution 85 | streamAnchorTags { 86 | nodes { 87 | anchorOffset 88 | anchorLabel 89 | } 90 | } 91 | skipIntro { 92 | startIntroTimeOffset 93 | stopIntroTimeOffset 94 | skipButtonDisplayTime 95 | skipButtonLabel 96 | } 97 | vodMediaType 98 | label 99 | contentType 100 | } 101 | ... on LiveMedia { 102 | geoLocation 103 | tvService 104 | title 105 | start 106 | stop 107 | editorialStart 108 | editorialStop 109 | encryption 110 | liveMediaType 111 | label 112 | } 113 | id 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /unshackle/core/services.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import click 4 | 5 | from unshackle.core.config import config 6 | from unshackle.core.service import Service 7 | from unshackle.core.utilities import import_module_by_path 8 | 9 | _service_dirs = config.directories.services 10 | if not isinstance(_service_dirs, list): 11 | _service_dirs = [_service_dirs] 12 | 13 | _SERVICES = sorted( 14 | (path for service_dir in _service_dirs for path in service_dir.glob("*/__init__.py")), 15 | key=lambda x: x.parent.stem, 16 | ) 17 | 18 | _MODULES = {path.parent.stem: getattr(import_module_by_path(path), path.parent.stem) for path in _SERVICES} 19 | 20 | _ALIASES = {tag: getattr(module, "ALIASES") for tag, module in _MODULES.items()} 21 | 22 | 23 | class Services(click.MultiCommand): 24 | """Lazy-loaded command group of project services.""" 25 | 26 | # Click-specific methods 27 | 28 | def list_commands(self, ctx: click.Context) -> list[str]: 29 | """Returns a list of all available Services as command names for Click.""" 30 | return Services.get_tags() 31 | 32 | def get_command(self, ctx: click.Context, name: str) -> click.Command: 33 | """Load the Service and return the Click CLI method.""" 34 | tag = Services.get_tag(name) 35 | try: 36 | service = Services.load(tag) 37 | except KeyError as e: 38 | available_services = self.list_commands(ctx) 39 | if not available_services: 40 | raise click.ClickException( 41 | f"There are no Services added yet, therefore the '{name}' Service could not be found." 42 | ) 43 | raise click.ClickException(f"{e}. Available Services: {', '.join(available_services)}") 44 | 45 | if hasattr(service, "cli"): 46 | return service.cli 47 | 48 | raise click.ClickException(f"Service '{tag}' has no 'cli' method configured.") 49 | 50 | # Methods intended to be used anywhere 51 | 52 | @staticmethod 53 | def get_tags() -> list[str]: 54 | """Returns a list of service tags from all available Services.""" 55 | return [x.parent.stem for x in _SERVICES] 56 | 57 | @staticmethod 58 | def get_path(name: str) -> Path: 59 | """Get the directory path of a command.""" 60 | tag = Services.get_tag(name) 61 | for service in _SERVICES: 62 | if service.parent.stem == tag: 63 | return service.parent 64 | raise KeyError(f"There is no Service added by the Tag '{name}'") 65 | 66 | @staticmethod 67 | def get_tag(value: str) -> str: 68 | """ 69 | Get the Service Tag (e.g. DSNP, not DisneyPlus/Disney+, etc.) by an Alias. 70 | Input value can be of any case-sensitivity. 71 | Original input value is returned if it did not match a service tag. 72 | """ 73 | original_value = value 74 | value = value.lower() 75 | for path in _SERVICES: 76 | tag = path.parent.stem 77 | if value in (tag.lower(), *_ALIASES.get(tag, [])): 78 | return tag 79 | return original_value 80 | 81 | @staticmethod 82 | def load(tag: str) -> Service: 83 | """Load a Service module by Service tag.""" 84 | module = _MODULES.get(tag) 85 | if not module: 86 | raise KeyError(f"There is no Service added by the Tag '{tag}'") 87 | return module 88 | 89 | 90 | __all__ = ("Services",) 91 | -------------------------------------------------------------------------------- /unshackle/services/MTSP/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from http.cookiejar import MozillaCookieJar 4 | from typing import Any, Optional 5 | import re 6 | 7 | import click 8 | from click import Context 9 | from bs4 import BeautifulSoup 10 | 11 | from unshackle.core.credential import Credential 12 | from unshackle.core.service import Service 13 | from unshackle.core.titles import Movie, Movies 14 | from unshackle.core.tracks import Chapter, Tracks 15 | from unshackle.core.manifests.dash import DASH 16 | 17 | 18 | class MTSP(Service): 19 | TITLE_RE = r"^(?:https?://(?:www\.)?magentasport\.de/event/[^/]+)?/[0-9]+/(?P[0-9]+)" 20 | 21 | @staticmethod 22 | @click.command(name="MTSP", short_help="https://magentasport.de", help=__doc__) 23 | @click.argument("title", type=str) 24 | @click.pass_context 25 | def cli(ctx: Context, **kwargs: Any) -> MTSP: 26 | return MTSP(ctx, **kwargs) 27 | 28 | def __init__(self, ctx: Context, title: str): 29 | self.title = title 30 | super().__init__(ctx) 31 | 32 | def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None: 33 | cache = self.cache.get(f"session_{credential.sha1}") 34 | if cache and not cache.expired: 35 | self.session.cookies.update({ 36 | "session": cache.data, 37 | "entitled": "1", 38 | }) 39 | return 40 | 41 | self.log.info("No cached session cookie, logging in...") 42 | r = self.session.get(self.config["endpoints"]["login_form"]) 43 | r.raise_for_status() 44 | 45 | tid, xsrf_name, xsrf_value = self.get_login_tid_xsrf(r.text) 46 | 47 | data = { 48 | "tid": tid, 49 | xsrf_name: xsrf_value, 50 | "pkc": "", 51 | "webauthn_supported": "false", 52 | "pw_usr": credential.username 53 | } 54 | r = self.session.post(self.config["endpoints"]["login_post"], data=data) 55 | r.raise_for_status() 56 | 57 | tid, xsrf_name, xsrf_value = self.get_login_tid_xsrf(r.text) 58 | 59 | data = { 60 | "tid": tid, 61 | xsrf_name: xsrf_value, 62 | "hidden_usr": credential.username, 63 | "pw_pwd": credential.password, 64 | "persist_session_displayed": "1", 65 | "persist_session": "on" 66 | } 67 | r = self.session.post(self.config["endpoints"]["login_post"], data=data) 68 | r.raise_for_status() 69 | 70 | session = self.session.cookies.get_dict().get('session') 71 | cache.set(session) 72 | 73 | def get_titles(self) -> Movies: 74 | video_id = re.match(self.TITLE_RE, self.title).group("video_id") 75 | r = self.session.get(self.config["endpoints"]["video_config"].format(video_id=video_id)) 76 | config = r.json() 77 | 78 | return Movies([Movie( 79 | id_=video_id, 80 | service=self.__class__, 81 | name=config["title"], 82 | language="de", 83 | data=config, 84 | )]) 85 | 86 | def get_tracks(self, title: Movie) -> Tracks: 87 | r = self.session.post(title.data['streamAccess']) 88 | access = r.json() 89 | tracks = DASH.from_url(access["data"]["stream"]["dash"]).to_tracks(title.language) 90 | return tracks 91 | 92 | def get_chapters(self, title: Movie) -> list[Chapter]: 93 | return [ 94 | ] 95 | 96 | def get_login_tid_xsrf(self, html): 97 | soup = BeautifulSoup(html, "html.parser") 98 | form = soup.find("form", id="login") 99 | xsrf = form.find("input", {"name": re.compile("^xsrf_")}) 100 | tid = form.find("input", {"name": "tid"}) 101 | return tid.get("value"), xsrf.get('name'), xsrf.get("value") 102 | -------------------------------------------------------------------------------- /unshackle/core/vaults.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Iterator, Optional, Union 2 | from uuid import UUID 3 | 4 | from unshackle.core.config import config 5 | from unshackle.core.utilities import import_module_by_path 6 | from unshackle.core.vault import Vault 7 | 8 | _VAULTS = sorted( 9 | (path for path in config.directories.vaults.glob("*.py") if path.stem.lower() != "__init__"), key=lambda x: x.stem 10 | ) 11 | 12 | _MODULES = {path.stem: getattr(import_module_by_path(path), path.stem) for path in _VAULTS} 13 | 14 | 15 | class Vaults: 16 | """Keeps hold of Key Vaults with convenience functions, e.g. searching all vaults.""" 17 | 18 | def __init__(self, service: Optional[str] = None): 19 | self.service = service or "" 20 | self.vaults = [] 21 | 22 | def __iter__(self) -> Iterator[Vault]: 23 | return iter(self.vaults) 24 | 25 | def __len__(self) -> int: 26 | return len(self.vaults) 27 | 28 | def load(self, type_: str, **kwargs: Any) -> bool: 29 | """Load a Vault into the vaults list. Returns True if successful, False otherwise.""" 30 | module = _MODULES.get(type_) 31 | if not module: 32 | raise ValueError(f"Unable to find vault command by the name '{type_}'.") 33 | try: 34 | vault = module(**kwargs) 35 | self.vaults.append(vault) 36 | return True 37 | except Exception: 38 | return False 39 | 40 | def load_critical(self, type_: str, **kwargs: Any) -> None: 41 | """Load a critical Vault that must succeed or raise an exception.""" 42 | module = _MODULES.get(type_) 43 | if not module: 44 | raise ValueError(f"Unable to find vault command by the name '{type_}'.") 45 | vault = module(**kwargs) 46 | self.vaults.append(vault) 47 | 48 | def get_key(self, kid: Union[UUID, str]) -> tuple[Optional[str], Optional[Vault]]: 49 | """Get Key from the first Vault it can by KID (Key ID) and Service.""" 50 | for vault in self.vaults: 51 | key = vault.get_key(kid, self.service) 52 | if key and key.count("0") != len(key): 53 | return key, vault 54 | return None, None 55 | 56 | def add_key(self, kid: Union[UUID, str], key: str, excluding: Optional[Vault] = None) -> int: 57 | """Add a KID:KEY to all Vaults, optionally with an exclusion.""" 58 | success = 0 59 | for vault in self.vaults: 60 | if vault != excluding and not vault.no_push: 61 | try: 62 | success += vault.add_key(self.service, kid, key) 63 | except (PermissionError, NotImplementedError): 64 | pass 65 | return success 66 | 67 | def add_keys(self, kid_keys: dict[Union[UUID, str], str]) -> int: 68 | """ 69 | Add multiple KID:KEYs to all Vaults. Duplicate Content Keys are skipped. 70 | PermissionErrors when the user cannot create Tables are absorbed and ignored. 71 | Vaults with no_push=True are skipped. 72 | """ 73 | success = 0 74 | for vault in self.vaults: 75 | if not vault.no_push: 76 | try: 77 | # Count each vault that successfully processes the keys (whether new or existing) 78 | vault.add_keys(self.service, kid_keys) 79 | success += 1 80 | except (PermissionError, NotImplementedError): 81 | pass 82 | return success 83 | 84 | 85 | __all__ = ("Vaults",) 86 | -------------------------------------------------------------------------------- /unshackle/utils/base62.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | base62 4 | ~~~~~~ 5 | 6 | Originated from http://blog.suminb.com/archives/558 7 | """ 8 | 9 | __title__ = "base62" 10 | __author__ = "Sumin Byeon" 11 | __email__ = "suminb@gmail.com" 12 | __version__ = "1.0.0" 13 | 14 | BASE = 62 15 | CHARSET_DEFAULT = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 16 | CHARSET_INVERTED = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 17 | 18 | 19 | def encode(n, charset=CHARSET_DEFAULT): 20 | """Encodes a given integer ``n``.""" 21 | 22 | chs = [] 23 | while n > 0: 24 | n, r = divmod(n, BASE) 25 | chs.insert(0, charset[r]) 26 | 27 | if not chs: 28 | return "0" 29 | 30 | return "".join(chs) 31 | 32 | 33 | def encodebytes(barray, charset=CHARSET_DEFAULT): 34 | """Encodes a bytestring into a base62 string. 35 | 36 | :param barray: A byte array 37 | :type barray: bytes 38 | :rtype: str 39 | """ 40 | 41 | _check_type(barray, bytes) 42 | 43 | # Count the number of leading zeros. 44 | leading_zeros_count = 0 45 | for i in range(len(barray)): 46 | if barray[i] != 0: 47 | break 48 | leading_zeros_count += 1 49 | 50 | # Encode the leading zeros as "0" followed by a character indicating the count. 51 | # This pattern may occur several times if there are many leading zeros. 52 | n, r = divmod(leading_zeros_count, len(charset) - 1) 53 | zero_padding = f"0{charset[-1]}" * n 54 | if r: 55 | zero_padding += f"0{charset[r]}" 56 | 57 | # Special case: the input is empty, or is entirely null bytes. 58 | if leading_zeros_count == len(barray): 59 | return zero_padding 60 | 61 | value = encode(int.from_bytes(barray, "big"), charset=charset) 62 | return zero_padding + value 63 | 64 | 65 | def decode(encoded, charset=CHARSET_DEFAULT): 66 | """Decodes a base62 encoded value ``encoded``. 67 | 68 | :type encoded: str 69 | :rtype: int 70 | """ 71 | _check_type(encoded, str) 72 | 73 | length, i, v = len(encoded), 0, 0 74 | for x in encoded: 75 | v += _value(x, charset=charset) * (BASE ** (length - (i + 1))) 76 | i += 1 77 | 78 | return v 79 | 80 | 81 | def decodebytes(encoded, charset=CHARSET_DEFAULT): 82 | """Decodes a string of base62 data into a bytes object. 83 | 84 | :param encoded: A string to be decoded in base62 85 | :type encoded: str 86 | :rtype: bytes 87 | """ 88 | 89 | leading_null_bytes = b"" 90 | while encoded.startswith("0") and len(encoded) >= 2: 91 | leading_null_bytes += b"\x00" * _value(encoded[1], charset) 92 | encoded = encoded[2:] 93 | 94 | decoded = decode(encoded, charset=charset) 95 | buf = bytearray() 96 | while decoded > 0: 97 | buf.append(decoded & 0xFF) 98 | decoded //= 256 99 | buf.reverse() 100 | 101 | return leading_null_bytes + bytes(buf) 102 | 103 | 104 | def _value(ch, charset): 105 | """Decodes an individual digit of a base62 encoded string.""" 106 | 107 | try: 108 | return charset.index(ch) 109 | except ValueError: 110 | raise ValueError("base62: Invalid character (%s)" % ch) 111 | 112 | 113 | def _check_type(value, expected_type): 114 | """Checks if the input is in an appropriate type.""" 115 | 116 | if not isinstance(value, expected_type): 117 | msg = "Expected {} object, not {}".format(expected_type, value.__class__.__name__) 118 | raise TypeError(msg) 119 | -------------------------------------------------------------------------------- /unshackle/commands/cfg.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import logging 3 | import sys 4 | 5 | import click 6 | from ruamel.yaml import YAML 7 | 8 | from unshackle.core.config import config, get_config_path 9 | from unshackle.core.constants import context_settings 10 | 11 | 12 | @click.command( 13 | short_help="Manage configuration values for the program and its services.", context_settings=context_settings 14 | ) 15 | @click.argument("key", type=str, required=False) 16 | @click.argument("value", type=str, required=False) 17 | @click.option("--unset", is_flag=True, default=False, help="Unset/remove the configuration value.") 18 | @click.option("--list", "list_", is_flag=True, default=False, help="List all set configuration values.") 19 | @click.pass_context 20 | def cfg(ctx: click.Context, key: str, value: str, unset: bool, list_: bool) -> None: 21 | """ 22 | Manage configuration values for the program and its services. 23 | 24 | \b 25 | Known Issues: 26 | - Config changes remove all comments of the changed files, which may hold critical data. (#14) 27 | """ 28 | if not key and not value and not list_: 29 | raise click.UsageError("Nothing to do.", ctx) 30 | 31 | if value: 32 | try: 33 | value = ast.literal_eval(value) 34 | except (ValueError, SyntaxError): 35 | pass # probably a str without quotes or similar, assume it's a string value 36 | 37 | log = logging.getLogger("cfg") 38 | 39 | yaml, data = YAML(), None 40 | yaml.default_flow_style = False 41 | 42 | config_path = get_config_path() or config.directories.user_configs / config.filenames.root_config 43 | if config_path.exists(): 44 | data = yaml.load(config_path) 45 | 46 | if not data: 47 | log.warning("No config file was found or it has no data, yet") 48 | # yaml.load() returns `None` if the input data is blank instead of a usable object 49 | # force a usable object by making one and removing the only item within it 50 | data = yaml.load("""__TEMP__: null""") 51 | del data["__TEMP__"] 52 | 53 | if list_: 54 | yaml.dump(data, sys.stdout) 55 | return 56 | 57 | key_items = key.split(".") 58 | parent_key = key_items[:-1] 59 | trailing_key = key_items[-1] 60 | 61 | is_write = value is not None 62 | is_delete = unset 63 | if is_write and is_delete: 64 | raise click.ClickException("You cannot set a value and use --unset at the same time.") 65 | 66 | if not is_write and not is_delete: 67 | data = data.mlget(key_items, default=KeyError) 68 | if data is KeyError: 69 | raise click.ClickException(f"Key '{key}' does not exist in the config.") 70 | yaml.dump(data, sys.stdout) 71 | else: 72 | try: 73 | parent_data = data 74 | if parent_key: 75 | parent_data = data.mlget(parent_key, default=data) 76 | if parent_data == data: 77 | for key in parent_key: 78 | if not hasattr(parent_data, key): 79 | parent_data[key] = {} 80 | parent_data = parent_data[key] 81 | if is_write: 82 | parent_data[trailing_key] = value 83 | log.info(f"Set {key} to {repr(value)}") 84 | elif is_delete: 85 | del parent_data[trailing_key] 86 | log.info(f"Unset {key}") 87 | except KeyError: 88 | raise click.ClickException(f"Key '{key}' does not exist in the config.") 89 | config_path.parent.mkdir(parents=True, exist_ok=True) 90 | yaml.dump(data, config_path) 91 | -------------------------------------------------------------------------------- /unshackle/core/credential.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import hashlib 5 | import re 6 | from pathlib import Path 7 | from typing import Optional, Union 8 | 9 | 10 | class Credential: 11 | """Username (or Email) and Password Credential.""" 12 | 13 | def __init__(self, username: str, password: str, extra: Optional[str] = None): 14 | self.username = username 15 | self.password = password 16 | self.extra = extra 17 | self.sha1 = hashlib.sha1(self.dumps().encode()).hexdigest() 18 | 19 | def __bool__(self) -> bool: 20 | return bool(self.username) and bool(self.password) 21 | 22 | def __str__(self) -> str: 23 | return self.dumps() 24 | 25 | def __repr__(self) -> str: 26 | return "{name}({items})".format( 27 | name=self.__class__.__name__, items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()]) 28 | ) 29 | 30 | def dumps(self) -> str: 31 | """Return credential data as a string.""" 32 | return f"{self.username}:{self.password}" + (f":{self.extra}" if self.extra else "") 33 | 34 | def dump(self, path: Union[Path, str]) -> int: 35 | """Write credential data to a file.""" 36 | if isinstance(path, str): 37 | path = Path(path) 38 | return path.write_text(self.dumps(), encoding="utf8") 39 | 40 | def as_base64(self, with_extra: bool = False, encode_password: bool = False, encode_extra: bool = False) -> str: 41 | """ 42 | Dump Credential as a Base64-encoded string in Basic Authorization style. 43 | encode_password and encode_extra will also Base64-encode the password and extra respectively. 44 | """ 45 | value = f"{self.username}:" 46 | if encode_password: 47 | value += base64.b64encode(self.password.encode()).decode() 48 | else: 49 | value += self.password 50 | if with_extra and self.extra: 51 | if encode_extra: 52 | value += f":{base64.b64encode(self.extra.encode()).decode()}" 53 | else: 54 | value += f":{self.extra}" 55 | return base64.b64encode(value.encode()).decode() 56 | 57 | @classmethod 58 | def loads(cls, text: str) -> Credential: 59 | """ 60 | Load credential from a text string. 61 | 62 | Format: {username}:{password} 63 | Rules: 64 | Only one Credential must be in this text contents. 65 | All whitespace before and after all text will be removed. 66 | Any whitespace between text will be kept and used. 67 | The credential can be spanned across one or multiple lines as long as it 68 | abides with all the above rules and the format. 69 | 70 | Example that follows the format and rules: 71 | `\tJohnd\noe@gm\n\rail.com\n:Pass1\n23\n\r \t \t` 72 | >>>Credential(username='Johndoe@gmail.com', password='Pass123') 73 | """ 74 | text = "".join([x.strip() for x in text.splitlines(keepends=False)]).strip() 75 | credential = re.fullmatch(r"^([^:]+?):([^:]+?)(?::(.+))?$", text) 76 | if credential: 77 | return cls(*credential.groups()) 78 | raise ValueError("No credentials found in text string. Expecting the format `username:password`") 79 | 80 | @classmethod 81 | def load(cls, path: Path) -> Credential: 82 | """ 83 | Load Credential from a file path. 84 | Use Credential.loads() for loading from text content and seeing the rules and 85 | format expected to be found in the URIs contents. 86 | """ 87 | return cls.loads(path.read_text("utf8")) 88 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "unshackle" 7 | version = "2.1.0" 8 | description = "Modular Movie, TV, and Music Archival Software." 9 | authors = [{ name = "unshackle team" }] 10 | requires-python = ">=3.10,<3.13" 11 | readme = "README.md" 12 | license = "GPL-3.0-only" 13 | keywords = [ 14 | "python", 15 | "downloader", 16 | "drm", 17 | "widevine", 18 | ] 19 | classifiers = [ 20 | "Development Status :: 4 - Beta", 21 | "Environment :: Console", 22 | "Intended Audience :: End Users/Desktop", 23 | "Natural Language :: English", 24 | "Operating System :: OS Independent", 25 | "Topic :: Multimedia :: Video", 26 | "Topic :: Security :: Cryptography", 27 | ] 28 | dependencies = [ 29 | "appdirs>=1.4.4,<2", 30 | "Brotli>=1.1.0,<2", 31 | "click>=8.1.8,<9", 32 | "construct>=2.8.8,<3", 33 | "crccheck>=1.3.0,<2", 34 | "fonttools>=4.0.0,<5", 35 | "jsonpickle>=3.0.4,<5", 36 | "langcodes>=3.4.0,<4", 37 | "lxml>=5.2.1,<7", 38 | "pproxy>=2.7.9,<3", 39 | "protobuf>=4.25.3,<7", 40 | "pycaption>=2.2.6,<3", 41 | "pycryptodomex>=3.20.0,<4", 42 | "pyjwt>=2.8.0,<3", 43 | "pymediainfo>=6.1.0,<8", 44 | "pymp4>=1.4.0,<2", 45 | "pymysql>=1.1.0,<2", 46 | "pywidevine[serve]>=1.8.0,<2", 47 | "PyYAML>=6.0.1,<7", 48 | "requests[socks]>=2.32.5,<3", 49 | "rich>=13.7.1,<15", 50 | "rlaphoenix.m3u8>=3.4.0,<4", 51 | "ruamel.yaml>=0.18.6,<0.19", 52 | "sortedcontainers>=2.4.0,<3", 53 | "subtitle-filter>=1.4.9,<2", 54 | "Unidecode>=1.3.8,<2", 55 | "urllib3>=2.2.1,<3", 56 | "chardet>=5.2.0,<6", 57 | "curl-cffi>=0.7.0b4,<0.14", 58 | "pyplayready>=0.6.3,<0.7", 59 | "httpx>=0.28.1,<0.29", 60 | "cryptography>=45.0.0,<47", 61 | "subby", 62 | "aiohttp-swagger3>=0.9.0,<1", 63 | "pysubs2>=1.7.0,<2", 64 | "PyExecJS>=1.5.1,<2", 65 | "webvtt-py>=0.5.1", 66 | "isodate>=0.7.2", 67 | "beaupy>=3.10.2", 68 | ] 69 | 70 | [project.urls] 71 | Homepage = "https://github.com/unshackle-dl/unshackle" 72 | Repository = "https://github.com/unshackle-dl/unshackle" 73 | Issues = "https://github.com/unshackle-dl/unshackle/issues" 74 | Discussions = "https://github.com/unshackle-dl/unshackle/discussions" 75 | Changelog = "https://github.com/unshackle-dl/unshackle/blob/master/CHANGELOG.md" 76 | 77 | [project.scripts] 78 | unshackle = "unshackle.core.__main__:main" 79 | 80 | [dependency-groups] 81 | dev = [ 82 | "pre-commit>=3.7.0,<5", 83 | "mypy>=1.9.0,<2", 84 | "mypy-protobuf>=3.6.0,<4", 85 | "types-protobuf>=4.24.0.20240408,<7", 86 | "types-PyMySQL>=1.1.0.1,<2", 87 | "types-requests>=2.31.0.20240406,<3", 88 | "isort>=5.13.2,<8", 89 | "ruff>=0.3.7,<0.15", 90 | "unshackle", 91 | ] 92 | 93 | [tool.hatch.build.targets.wheel] 94 | packages = ["unshackle"] 95 | 96 | [tool.hatch.build.targets.sdist] 97 | include = [ 98 | "CHANGELOG.md", 99 | "README.md", 100 | "LICENSE", 101 | ] 102 | 103 | [tool.ruff] 104 | force-exclude = true 105 | line-length = 120 106 | 107 | [tool.ruff.lint] 108 | select = ["E4", "E7", "E9", "F", "W"] 109 | 110 | [tool.isort] 111 | line_length = 118 112 | 113 | [tool.mypy] 114 | check_untyped_defs = true 115 | disallow_incomplete_defs = true 116 | disallow_untyped_defs = true 117 | follow_imports = "silent" 118 | ignore_missing_imports = true 119 | no_implicit_optional = true 120 | 121 | [tool.uv.sources] 122 | unshackle = { workspace = true } 123 | subby = { git = "https://github.com/vevv/subby.git", rev = "5a925c367ffb3f5e53fd114ae222d3be1fdff35d" } 124 | -------------------------------------------------------------------------------- /unshackle/services/NF/MSL/schemes/KeyExchangeRequest.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from .. import KeyExchangeSchemes 4 | from ..MSLObject import MSLObject 5 | 6 | 7 | # noinspection PyPep8Naming 8 | class KeyExchangeRequest(MSLObject): 9 | def __init__(self, scheme, keydata): 10 | """ 11 | Session key exchange data from a requesting entity. 12 | https://github.com/Netflix/msl/wiki/Key-Exchange-%28Configuration%29 13 | 14 | :param scheme: Key Exchange Scheme identifier 15 | :param keydata: Key Request data 16 | """ 17 | self.scheme = str(scheme) 18 | self.keydata = keydata 19 | 20 | @classmethod 21 | def AsymmetricWrapped(cls, keypairid, mechanism, publickey): 22 | """ 23 | Asymmetric wrapped key exchange uses a generated ephemeral asymmetric key pair for key exchange. It will 24 | typically be used when there is no other data or keys from which to base secure key exchange. 25 | 26 | This mechanism provides perfect forward secrecy but does not guarantee that session keys will only be available 27 | to the requesting entity if the requesting MSL stack has been modified to perform the operation on behalf of a 28 | third party. 29 | 30 | > Key Pair ID 31 | 32 | The key pair ID is included as a sanity check. 33 | 34 | > Mechanism & Public Key 35 | 36 | The following mechanisms are associated public key formats are currently supported. 37 | 38 | Field Public Key Format Description 39 | RSA SPKI RSA-OAEP encrypt/decrypt 40 | ECC SPKI ECIES encrypt/decrypt 41 | JWEJS_RSA SPKI RSA-OAEP JSON Web Encryption JSON Serialization 42 | JWE_RSA SPKI RSA-OAEP JSON Web Encryption Compact Serialization 43 | JWK_RSA SPKI RSA-OAEP JSON Web Key 44 | JWK_RSAES SPKI RSA PKCS#1 JSON Web Key 45 | 46 | :param keypairid: key pair ID 47 | :param mechanism: asymmetric key type 48 | :param publickey: public key 49 | """ 50 | return cls( 51 | scheme=KeyExchangeSchemes.AsymmetricWrapped, 52 | keydata={ 53 | "keypairid": keypairid, 54 | "mechanism": mechanism, 55 | "publickey": base64.b64encode(publickey).decode("utf-8") 56 | } 57 | ) 58 | 59 | @classmethod 60 | def Widevine(cls, keyrequest): 61 | """ 62 | Google Widevine provides a secure key exchange mechanism. When requested the Widevine component will issue a 63 | one-time use key request. The Widevine server library can be used to authenticate the request and return 64 | randomly generated symmetric keys in a protected key response bound to the request and Widevine client library. 65 | The key response also specifies the key identities, types and their permitted usage. 66 | 67 | The Widevine key request also contains a model identifier and a unique device identifier with an expectation of 68 | long-term persistence. These values are available from the Widevine client library and can be retrieved from 69 | the key request by the Widevine server library. 70 | 71 | The Widevine client library will protect the returned keys from inspection or misuse. 72 | 73 | :param keyrequest: Base64-encoded Widevine CDM license challenge (PSSH: b'\x0A\x7A\x00\x6C\x38\x2B') 74 | """ 75 | if not isinstance(keyrequest, str): 76 | keyrequest = base64.b64encode(keyrequest).decode() 77 | return cls( 78 | scheme=KeyExchangeSchemes.Widevine, 79 | keydata={"keyrequest": keyrequest} 80 | ) 81 | -------------------------------------------------------------------------------- /unshackle/core/api/download_worker.py: -------------------------------------------------------------------------------- 1 | """Standalone worker process entry point for executing download jobs.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import logging 7 | import sys 8 | import traceback 9 | from pathlib import Path 10 | from typing import Any, Dict 11 | 12 | from .download_manager import _perform_download 13 | 14 | log = logging.getLogger("download_worker") 15 | 16 | 17 | def _read_payload(path: Path) -> Dict[str, Any]: 18 | with path.open("r", encoding="utf-8") as handle: 19 | return json.load(handle) 20 | 21 | 22 | def _write_result(path: Path, payload: Dict[str, Any]) -> None: 23 | path.parent.mkdir(parents=True, exist_ok=True) 24 | with path.open("w", encoding="utf-8") as handle: 25 | json.dump(payload, handle) 26 | 27 | 28 | def main(argv: list[str]) -> int: 29 | if len(argv) not in [3, 4]: 30 | print( 31 | "Usage: python -m unshackle.core.api.download_worker [progress_path]", 32 | file=sys.stderr, 33 | ) 34 | return 2 35 | 36 | payload_path = Path(argv[1]) 37 | result_path = Path(argv[2]) 38 | progress_path = Path(argv[3]) if len(argv) > 3 else None 39 | 40 | result: Dict[str, Any] = {} 41 | exit_code = 0 42 | 43 | try: 44 | payload = _read_payload(payload_path) 45 | job_id = payload["job_id"] 46 | service = payload["service"] 47 | title_id = payload["title_id"] 48 | params = payload.get("parameters", {}) 49 | 50 | log.info(f"Worker starting job {job_id} ({service}:{title_id})") 51 | 52 | def progress_callback(progress_data: Dict[str, Any]) -> None: 53 | """Write progress updates to file for main process to read.""" 54 | if progress_path: 55 | try: 56 | log.info(f"Writing progress update: {progress_data}") 57 | _write_result(progress_path, progress_data) 58 | log.info(f"Progress update written to {progress_path}") 59 | except Exception as e: 60 | log.error(f"Failed to write progress update: {e}") 61 | 62 | output_files = _perform_download( 63 | job_id, service, title_id, params, cancel_event=None, progress_callback=progress_callback 64 | ) 65 | 66 | result = {"status": "success", "output_files": output_files} 67 | 68 | except Exception as exc: # noqa: BLE001 - capture for parent process 69 | from unshackle.core.api.errors import categorize_exception 70 | 71 | exit_code = 1 72 | tb = traceback.format_exc() 73 | log.error(f"Worker failed with error: {exc}") 74 | 75 | api_error = categorize_exception( 76 | exc, 77 | context={ 78 | "service": payload.get("service") if "payload" in locals() else None, 79 | "title_id": payload.get("title_id") if "payload" in locals() else None, 80 | "job_id": payload.get("job_id") if "payload" in locals() else None, 81 | }, 82 | ) 83 | 84 | result = { 85 | "status": "error", 86 | "message": str(exc), 87 | "error_details": api_error.message, 88 | "error_code": api_error.error_code.value, 89 | "traceback": tb, 90 | } 91 | 92 | finally: 93 | try: 94 | _write_result(result_path, result) 95 | except Exception as exc: # noqa: BLE001 - last resort logging 96 | log.error(f"Failed to write worker result file: {exc}") 97 | 98 | return exit_code 99 | 100 | 101 | if __name__ == "__main__": 102 | sys.exit(main(sys.argv)) 103 | -------------------------------------------------------------------------------- /unshackle/core/utils/sslciphers.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | from typing import Optional 3 | 4 | from requests.adapters import HTTPAdapter 5 | 6 | 7 | class SSLCiphers(HTTPAdapter): 8 | """ 9 | Custom HTTP Adapter to change the TLS Cipher set and security requirements. 10 | 11 | Security Level may optionally be provided. A level above 0 must be used at all times. 12 | A list of Security Levels and their security is listed below. Usually 2 is used by default. 13 | Do not set the Security level via @SECLEVEL in the cipher list. 14 | 15 | Level 0: 16 | Everything is permitted. This retains compatibility with previous versions of OpenSSL. 17 | 18 | Level 1: 19 | The security level corresponds to a minimum of 80 bits of security. Any parameters 20 | offering below 80 bits of security are excluded. As a result RSA, DSA and DH keys 21 | shorter than 1024 bits and ECC keys shorter than 160 bits are prohibited. All export 22 | cipher suites are prohibited since they all offer less than 80 bits of security. SSL 23 | version 2 is prohibited. Any cipher suite using MD5 for the MAC is also prohibited. 24 | 25 | Level 2: 26 | Security level set to 112 bits of security. As a result RSA, DSA and DH keys shorter 27 | than 2048 bits and ECC keys shorter than 224 bits are prohibited. In addition to the 28 | level 1 exclusions any cipher suite using RC4 is also prohibited. SSL version 3 is 29 | also not allowed. Compression is disabled. 30 | 31 | Level 3: 32 | Security level set to 128 bits of security. As a result RSA, DSA and DH keys shorter 33 | than 3072 bits and ECC keys shorter than 256 bits are prohibited. In addition to the 34 | level 2 exclusions cipher suites not offering forward secrecy are prohibited. TLS 35 | versions below 1.1 are not permitted. Session tickets are disabled. 36 | 37 | Level 4: 38 | Security level set to 192 bits of security. As a result RSA, DSA and DH keys shorter 39 | than 7680 bits and ECC keys shorter than 384 bits are prohibited. Cipher suites using 40 | SHA1 for the MAC are prohibited. TLS versions below 1.2 are not permitted. 41 | 42 | Level 5: 43 | Security level set to 256 bits of security. As a result RSA, DSA and DH keys shorter 44 | than 15360 bits and ECC keys shorter than 512 bits are prohibited. 45 | """ 46 | 47 | def __init__(self, cipher_list: Optional[str] = None, security_level: int = 0, *args, **kwargs): 48 | if cipher_list: 49 | if not isinstance(cipher_list, str): 50 | raise TypeError(f"Expected cipher_list to be a str, not {cipher_list!r}") 51 | if "@SECLEVEL" in cipher_list: 52 | raise ValueError("You must not specify the Security Level manually in the cipher list.") 53 | if not isinstance(security_level, int): 54 | raise TypeError(f"Expected security_level to be an int, not {security_level!r}") 55 | if security_level not in range(6): 56 | raise ValueError(f"The security_level must be a value between 0 and 5, not {security_level}") 57 | 58 | if not cipher_list: 59 | # cpython's default cipher list differs to Python-requests cipher list 60 | cipher_list = "DEFAULT" 61 | 62 | cipher_list += f":@SECLEVEL={security_level}" 63 | 64 | ctx = ssl.create_default_context() 65 | ctx.check_hostname = False # For some reason this is needed to avoid a verification error 66 | ctx.set_ciphers(cipher_list) 67 | 68 | self._ssl_context = ctx 69 | super().__init__(*args, **kwargs) 70 | 71 | def init_poolmanager(self, *args, **kwargs): 72 | kwargs["ssl_context"] = self._ssl_context 73 | return super().init_poolmanager(*args, **kwargs) 74 | 75 | def proxy_manager_for(self, *args, **kwargs): 76 | kwargs["ssl_context"] = self._ssl_context 77 | return super().proxy_manager_for(*args, **kwargs) 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## What is envied? 2 | 3 | Envied is a fork of [Devine](https://github.com/devine-dl/devine/). The name 'envied' is an anagram of Devine, and as such, pays homage to the original author rlaphoenix. 4 | Is is based on v 2.1.0 of unshackle. It is a powerful archival tool for downloading movies, TV shows, and music from streaming services. Built with a focus on modularity and extensibility, it provides a robust framework for content acquisition with support for DRM-protected content. 5 | 6 | No commands have been changed 'uv run unshackle' still works as usual. 7 | 8 | The major difference is that envied comes complete and needs little configuration. 9 | CDM and services are taken care of. 10 | The prime reason for the existence of envied is a --select-titles function. 11 | 12 | If you already use unshackle you'll probably just want to replace envied/unshackle/unshackle.yaml 13 | with your own. But the exisiting yaml is close to working - just needs a few directory locations. 14 | ## Select Titles Feature 15 | ![--select-titles option](https://github.com/vinefeeder/envied/blob/main/img/envied1.png) 16 | 17 | ## Divergence from Envied's Parent 18 | - **select-titles option** avoid the uncertainty of -w S26E03 gobbledegook to get your video 19 | - **Singleton Design Pattern** Good coding practice: where possible, a single instance of a Class is created and re-used. Saving time and resources. 20 | - **Multitron Design Pattern** For those times when a Singleton will not do. Re-use Classes with care for the calling parameters. 21 | - **Clear Branding** Clear presentation of the program name! 22 | - **No Free Ride for Spammers** Envied will not implement any methods used to connect to Decrypt Labs 23 | 24 | ## Quick Start 25 | 26 | ### Installation 27 | 28 | This installs the latest version directly from the GitHub repository: 29 | 30 | ```shell 31 | git clone https://github.com/vinefeeder/envied.git 32 | cd unshackle 33 | uv sync 34 | uv run unshackle --help 35 | ``` 36 | 37 | ### Install unshackle as a global (per-user) tool 38 | 39 | ```bash 40 | uv tool install git+https://github.com/vinefeeder/envied.git 41 | # Then run: 42 | uvx unshackle --help # or just `unshackle` once PATH updated 43 | ``` 44 | 45 | > [!NOTE] 46 | > After installation, you may need to add the installation path to your PATH environment variable if prompted. 47 | 48 | > **Recommended:** Use `uv run unshackle` instead of direct command execution to ensure proper virtual environment activation. 49 | 50 | 51 | ### Basic Usage 52 | 53 | ```shell 54 | # Check available commands 55 | uv run unshackle --help 56 | 57 | # Configure your settings 58 | git clone https://github.com/vinefeeder/envied.git 59 | cd unshackle 60 | uv sync 61 | uv run unshackle --help 62 | 63 | # Download content (requires configured services) 64 | uv run unshackle dl SERVICE_NAME CONTENT_ID 65 | ``` 66 | 67 | ## Documentation 68 | 69 | For comprehensive setup guides, configuration options, and advanced usage: 70 | 71 | 📖 **[Visit their WIKI](https://github.com/unshackle-dl/unshackle/wiki)** 72 | 73 | The WIKI contains detailed information on: 74 | 75 | - Service configuration 76 | - DRM configuration 77 | - Advanced features and troubleshooting 78 | 79 | For guidance on creating services, see their [WIKI documentation](https://github.com/unshackle-dl/unshackle/wiki). 80 | 81 | ## End User License Agreement 82 | 83 | Envied, and it's community pages, should be treated with the same kindness as other projects. 84 | Please refrain from spam or asking for questions that infringe upon a Service's End User License Agreement. 85 | 86 | 1. Do not use envied for any purposes of which you do not have the rights to do so. 87 | 2. Do not share or request infringing content; this includes widevine Provision Keys, Content Encryption Keys, 88 | or Service API Calls or Code. 89 | 3. The Core codebase is meant to stay Free and Open-Source while the Service code should be kept private. 90 | 4. Do not sell any part of this project, neither alone nor as part of a bundle. 91 | If you paid for this software or received it as part of a bundle following payment, you should demand your money 92 | back immediately. 93 | 5. Be kind to one another and do not single anyone out. 94 | 95 | ## Licensing 96 | 97 | This software is licensed under the terms of [GNU General Public License, Version 3.0](LICENSE). 98 | You can find a copy of the license in the LICENSE file in the root folder. 99 | -------------------------------------------------------------------------------- /unshackle/core/drm/clearkey.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import shutil 5 | from pathlib import Path 6 | from typing import Optional, Union 7 | from urllib.parse import urljoin 8 | 9 | from Cryptodome.Cipher import AES 10 | from Cryptodome.Util.Padding import unpad 11 | from curl_cffi.requests import Session as CurlSession 12 | from m3u8.model import Key 13 | from requests import Session 14 | 15 | 16 | class ClearKey: 17 | """AES Clear Key DRM System.""" 18 | 19 | def __init__(self, key: Union[bytes, str], iv: Optional[Union[bytes, str]] = None): 20 | """ 21 | Generally IV should be provided where possible. If not provided, it will be 22 | set to \x00 of the same bit-size of the key. 23 | """ 24 | if isinstance(key, str): 25 | key = bytes.fromhex(key.replace("0x", "")) 26 | if not isinstance(key, bytes): 27 | raise ValueError(f"Expected AES Key to be bytes, not {key!r}") 28 | if not iv: 29 | iv = b"\x00" 30 | if isinstance(iv, str): 31 | iv = bytes.fromhex(iv.replace("0x", "")) 32 | if not isinstance(iv, bytes): 33 | raise ValueError(f"Expected IV to be bytes, not {iv!r}") 34 | 35 | if len(iv) < len(key): 36 | iv = iv * (len(key) - len(iv) + 1) 37 | 38 | self.key: bytes = key 39 | self.iv: bytes = iv 40 | 41 | def decrypt(self, path: Path) -> None: 42 | """Decrypt a Track with AES Clear Key DRM.""" 43 | if not path or not path.exists(): 44 | raise ValueError("Tried to decrypt a file that does not exist.") 45 | 46 | decrypted = AES.new(self.key, AES.MODE_CBC, self.iv).decrypt(path.read_bytes()) 47 | 48 | try: 49 | decrypted = unpad(decrypted, AES.block_size) 50 | except ValueError: 51 | # the decrypted data is likely already in the block size boundary 52 | pass 53 | 54 | decrypted_path = path.with_suffix(f".decrypted{path.suffix}") 55 | decrypted_path.write_bytes(decrypted) 56 | 57 | path.unlink() 58 | shutil.move(decrypted_path, path) 59 | 60 | @classmethod 61 | def from_m3u_key(cls, m3u_key: Key, session: Optional[Session] = None) -> ClearKey: 62 | """ 63 | Load a ClearKey from an M3U(8) Playlist's EXT-X-KEY. 64 | 65 | Parameters: 66 | m3u_key: A Key object parsed from a m3u(8) playlist using 67 | the `m3u8` library. 68 | session: Optional session used to request external URIs with. 69 | Useful to set headers, proxies, cookies, and so forth. 70 | """ 71 | if not isinstance(m3u_key, Key): 72 | raise ValueError(f"Provided M3U Key is in an unexpected type {m3u_key!r}") 73 | if not isinstance(session, (Session, CurlSession, type(None))): 74 | raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not a {type(session)}") 75 | 76 | if not m3u_key.method.startswith("AES"): 77 | raise ValueError(f"Provided M3U Key is not an AES Clear Key, {m3u_key.method}") 78 | if not m3u_key.uri: 79 | raise ValueError("No URI in M3U Key, unable to get Key.") 80 | 81 | if not session: 82 | session = Session() 83 | 84 | if not session.headers.get("User-Agent"): 85 | # commonly needed default for HLS playlists 86 | session.headers["User-Agent"] = "smartexoplayer/1.1.0 (Linux;Android 8.0.0) ExoPlayerLib/2.13.3" 87 | 88 | if m3u_key.uri.startswith("data:"): 89 | media_types, data = m3u_key.uri[5:].split(",") 90 | media_types = media_types.split(";") 91 | if "base64" in media_types: 92 | data = base64.b64decode(data) 93 | key = data 94 | else: 95 | url = urljoin(m3u_key.base_uri, m3u_key.uri) 96 | res = session.get(url) 97 | res.raise_for_status() 98 | if not res.content: 99 | raise EOFError("Unexpected Empty Response by M3U Key URI.") 100 | if len(res.content) < 16: 101 | raise EOFError(f"Unexpected Length of Key ({len(res.content)} bytes) in M3U Key.") 102 | key = res.content 103 | 104 | if m3u_key.iv: 105 | iv = bytes.fromhex(m3u_key.iv.replace("0x", "")) 106 | else: 107 | iv = None 108 | 109 | return cls(key=key, iv=iv) 110 | 111 | 112 | __all__ = ("ClearKey",) 113 | -------------------------------------------------------------------------------- /unshackle/services/NRK/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from http.cookiejar import MozillaCookieJar 4 | from typing import Any, Optional, Union 5 | from functools import partial 6 | from pathlib import Path 7 | import json 8 | import re 9 | 10 | import click 11 | import isodate 12 | from click import Context 13 | 14 | from unshackle.core.credential import Credential 15 | from unshackle.core.service import Service 16 | from unshackle.core.titles import Movie, Movies, Episode, Series 17 | from unshackle.core.tracks import Track, Chapter, Tracks, Video, Audio, Subtitle 18 | from unshackle.core.manifests.hls import HLS 19 | from unshackle.core.manifests.dash import DASH 20 | from rich.console import Console 21 | 22 | 23 | class NRK(Service): 24 | """ 25 | Service code for NRK TV (https://tv.nrk.no) 26 | 27 | \b 28 | Version: 1.0.0 29 | Author: lambda 30 | Authorization: None 31 | Robustness: 32 | Unencrypted: 1080p, DD5.1 33 | """ 34 | 35 | GEOFENCE = ("no",) 36 | TITLE_RE = r"^https://tv.nrk.no/serie/fengselseksperimentet/sesong/1/episode/(?P.+)$" 37 | 38 | @staticmethod 39 | @click.command(name="NRK", short_help="https://tv.nrk.no", help=__doc__) 40 | @click.argument("title", type=str) 41 | @click.pass_context 42 | def cli(ctx: Context, **kwargs: Any) -> NRK: 43 | return NRK(ctx, **kwargs) 44 | 45 | def __init__(self, ctx: Context, title: str): 46 | self.title = title 47 | super().__init__(ctx) 48 | 49 | def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None: 50 | pass 51 | 52 | def get_titles(self) -> Union[Movies, Series]: 53 | match = re.match(self.TITLE_RE, self.title) 54 | if match: 55 | content_id = match.group("content_id") 56 | EPISODE = True 57 | MOVIE = False 58 | else: 59 | content_id = self.title.split('/')[-1] 60 | MOVIE = True 61 | EPISODE = False 62 | 63 | r = self.session.get(self.config["endpoints"]["content"].format(content_id=content_id)) 64 | item = r.json() 65 | # development only 66 | #console = Console() 67 | #console.print_json(data=item) 68 | if EPISODE: 69 | episode, name = item["programInformation"]["titles"]["title"].split(". ", maxsplit=1) 70 | return Series([Episode( 71 | id_=content_id, 72 | service=self.__class__, 73 | language="nb", 74 | year=item["moreInformation"]["productionYear"], 75 | title=item["_links"]["seriesPage"]["title"], 76 | name=name, 77 | season=item["_links"]["season"]["name"], 78 | number=episode, 79 | )]) 80 | if MOVIE: 81 | name = item["programInformation"]["titles"]["title"] 82 | return Movies([Movie( 83 | id_ = content_id, 84 | service=self.__class__, 85 | name = name, 86 | 87 | year = item["moreInformation"]["productionYear"], 88 | language="nb", 89 | data = None, 90 | description = None,)]) 91 | 92 | 93 | 94 | def get_tracks(self, title: Union[Episode, Movie]) -> Tracks: 95 | r = self.session.get(self.config["endpoints"]["manifest"].format(content_id=title.id)) 96 | manifest = r.json() 97 | tracks = Tracks() 98 | 99 | for asset in manifest["playable"]["assets"]: 100 | if asset["format"] == "HLS": 101 | tracks += Tracks(HLS.from_url(asset["url"], session=self.session).to_tracks("nb")) 102 | 103 | 104 | for sub in manifest["playable"]["subtitles"]: 105 | tracks.add(Subtitle( 106 | codec=Subtitle.Codec.WebVTT, 107 | language=sub["language"], 108 | url=sub["webVtt"], 109 | sdh=sub["type"] == "ttv", 110 | )) 111 | 112 | 113 | for track in tracks: 114 | track.needs_proxy = True 115 | 116 | # if isinstance(track, Audio) and track.channels == 6.0: 117 | # track.channels = 5.1 118 | 119 | return tracks 120 | 121 | def get_chapters(self, title: Union[Episode, Movie]) -> list[Chapter]: 122 | r = self.session.get(self.config["endpoints"]["metadata"].format(content_id=title.id)) 123 | sdi = r.json()["skipDialogInfo"] 124 | 125 | chapters = [] 126 | if sdi["endIntroInSeconds"]: 127 | if sdi["startIntroInSeconds"]: 128 | chapters.append(Chapter(timestamp=0)) 129 | 130 | chapters |= [ 131 | Chapter(timestamp=sdi["startIntroInSeconds"], name="Intro"), 132 | Chapter(timestamp=sdi["endIntroInSeconds"]) 133 | ] 134 | 135 | if sdi["startCreditsInSeconds"]: 136 | if not chapters: 137 | chapters.append(Chapter(timestamp=0)) 138 | 139 | credits = isodate.parse_duration(sdi["startCredits"]) 140 | chapters.append(Chapter(credits.total_seconds(), name="Credits")) 141 | 142 | return chapters 143 | -------------------------------------------------------------------------------- /unshackle/core/proxies/windscribevpn.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import re 4 | from typing import Optional 5 | 6 | import requests 7 | 8 | from unshackle.core.proxies.proxy import Proxy 9 | 10 | 11 | class WindscribeVPN(Proxy): 12 | def __init__(self, username: str, password: str, server_map: Optional[dict[str, str]] = None): 13 | """ 14 | Proxy Service using WindscribeVPN Service Credentials. 15 | 16 | A username and password must be provided. These are Service Credentials, not your Login Credentials. 17 | The Service Credentials can be found here: https://windscribe.com/getconfig/openvpn 18 | """ 19 | if not username: 20 | raise ValueError("No Username was provided to the WindscribeVPN Proxy Service.") 21 | if not password: 22 | raise ValueError("No Password was provided to the WindscribeVPN Proxy Service.") 23 | 24 | if server_map is not None and not isinstance(server_map, dict): 25 | raise TypeError(f"Expected server_map to be a dict mapping a region to a hostname, not '{server_map!r}'.") 26 | 27 | self.username = username 28 | self.password = password 29 | self.server_map = server_map or {} 30 | 31 | self.countries = self.get_countries() 32 | 33 | def __repr__(self) -> str: 34 | countries = len(set(x.get("country_code") for x in self.countries if x.get("country_code"))) 35 | servers = sum( 36 | len(host) 37 | for location in self.countries 38 | for group in location.get("groups", []) 39 | for host in group.get("hosts", []) 40 | ) 41 | 42 | return f"{countries} Countr{['ies', 'y'][countries == 1]} ({servers} Server{['s', ''][servers == 1]})" 43 | 44 | def get_proxy(self, query: str) -> Optional[str]: 45 | """ 46 | Get an HTTPS proxy URI for a WindscribeVPN server. 47 | 48 | Note: Windscribe's static OpenVPN credentials work reliably on US, AU, and NZ servers. 49 | """ 50 | query = query.lower() 51 | supported_regions = {"us", "au", "nz"} 52 | 53 | if query not in supported_regions and query not in self.server_map: 54 | raise ValueError( 55 | f"Windscribe proxy does not currently support the '{query.upper()}' region. " 56 | f"Supported regions with reliable credentials: {', '.join(sorted(supported_regions))}. " 57 | ) 58 | 59 | if query in self.server_map: 60 | hostname = self.server_map[query] 61 | else: 62 | if re.match(r"^[a-z]+$", query): 63 | hostname = self.get_random_server(query) 64 | else: 65 | raise ValueError(f"The query provided is unsupported and unrecognized: {query}") 66 | 67 | if not hostname: 68 | return None 69 | 70 | hostname = hostname.split(':')[0] 71 | return f"https://{self.username}:{self.password}@{hostname}:443" 72 | 73 | def get_random_server(self, country_code: str) -> Optional[str]: 74 | """ 75 | Get a random server hostname for a country. 76 | 77 | Returns None if no servers are available for the country. 78 | """ 79 | for location in self.countries: 80 | if location.get("country_code", "").lower() == country_code.lower(): 81 | hostnames = [] 82 | for group in location.get("groups", []): 83 | for host in group.get("hosts", []): 84 | if hostname := host.get("hostname"): 85 | hostnames.append(hostname) 86 | 87 | if hostnames: 88 | return random.choice(hostnames) 89 | 90 | return None 91 | 92 | @staticmethod 93 | def get_countries() -> list[dict]: 94 | """Get a list of available Countries and their metadata.""" 95 | res = requests.get( 96 | url="https://assets.windscribe.com/serverlist/firefox/1/1", 97 | headers={ 98 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", 99 | "Content-Type": "application/json", 100 | }, 101 | ) 102 | if not res.ok: 103 | raise ValueError(f"Failed to get a list of WindscribeVPN locations [{res.status_code}]") 104 | 105 | try: 106 | data = res.json() 107 | return data.get("data", []) 108 | except json.JSONDecodeError: 109 | raise ValueError("Could not decode list of WindscribeVPN locations, not JSON data.") 110 | -------------------------------------------------------------------------------- /post_processing/mkv_to_mp4.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Recursively convert form .mkv files to mp4 using ffmpeg. 4 | 5 | Default behavior: 6 | - Find all *.mkv under a given root 7 | - Convert to mp4 with the same base name, in the same folder 8 | e.g. "Taggart S01E02.mkv" -> "Taggart S01E02.mp4" 9 | 10 | Requires: 11 | - ffmpeg available on PATH 12 | """ 13 | 14 | from __future__ import annotations 15 | 16 | import argparse 17 | import shutil 18 | import subprocess 19 | import sys 20 | from pathlib import Path 21 | 22 | 23 | def parse_args() -> argparse.Namespace: 24 | p = argparse.ArgumentParser( 25 | description="Convert mkv files to mp4 (recursively) using ffmpeg." 26 | ) 27 | p.add_argument( 28 | "root", 29 | nargs="?", 30 | default=".", 31 | help="Root directory to scan (default: current directory).", 32 | ) 33 | p.add_argument( 34 | "--ext", 35 | default=".mkv", 36 | help="Input extension to scan for (default: .mkv).", 37 | ) 38 | p.add_argument( 39 | "--out-ext", 40 | default=".mp4", 41 | help="Output extension to write (default: .mp4).", 42 | ) 43 | p.add_argument( 44 | "--overwrite", 45 | action="store_true", 46 | help="Overwrite existing output files.", 47 | ) 48 | p.add_argument( 49 | "--dry-run", 50 | action="store_true", 51 | help="Print what would be done, but don't run ffmpeg.", 52 | ) 53 | p.add_argument( 54 | "--verbose", 55 | action="store_true", 56 | help="Print ffmpeg output for each file.", 57 | ) 58 | return p.parse_args() 59 | 60 | 61 | def run_convert( 62 | ffmpeg_path: str, 63 | in_file: Path, 64 | out_file: str, 65 | overwrite: bool, 66 | dry_run: bool, 67 | verbose: bool, 68 | ) -> bool: 69 | 70 | out_file = Path(out_file) 71 | in_file = Path(in_file) 72 | 73 | if out_file.exists() and not overwrite: 74 | print(f"SKIP (exists): {out_file}") 75 | return True 76 | 77 | cmd = [ 78 | ffmpeg_path, 79 | "-i", 80 | in_file, 81 | "-c", 82 | "copy", 83 | out_file, 84 | ] 85 | 86 | if dry_run: 87 | print("DRY:", " ".join(map(str, cmd))) 88 | return True 89 | 90 | # Ensure parent exists (it should, but just in case you change output logic later) 91 | out_file.parent.mkdir(parents=True, exist_ok=True) 92 | 93 | try: 94 | proc = subprocess.run( 95 | cmd, 96 | stdout=subprocess.PIPE, 97 | stderr=subprocess.STDOUT, 98 | text=True, 99 | check=False, 100 | ) 101 | except FileNotFoundError: 102 | print("ERROR: ffmpeg not found (is MKVToolNix installed and on PATH?)", file=sys.stderr) 103 | return False 104 | 105 | if verbose and proc.stdout: 106 | print(proc.stdout.rstrip()) 107 | 108 | if proc.returncode == 0 and out_file.exists(): 109 | print(f"OK : {in_file.name} -> {out_file.name}") 110 | return True 111 | 112 | print(f"FAIL: {in_file}", file=sys.stderr) 113 | if proc.stdout: 114 | print(proc.stdout.rstrip(), file=sys.stderr) 115 | return False 116 | 117 | 118 | def main() -> int: 119 | args = parse_args() 120 | 121 | ffmpeg_path = shutil.which("ffmpeg") 122 | if not ffmpeg_path: 123 | print("ERROR: ffmpeg not found on PATH. Install MKVToolNix.", file=sys.stderr) 124 | return 2 125 | 126 | root = Path(args.root).expanduser().resolve() 127 | if not root.exists(): 128 | print(f"ERROR: Root path does not exist: {root}", file=sys.stderr) 129 | return 2 130 | 131 | in_ext = args.ext if args.ext.startswith(".") else f".{args.ext}" 132 | out_ext = args.out_ext if args.out_ext.startswith(".") else f".{args.out_ext}" 133 | 134 | files = sorted(p for p in root.rglob(f"*{in_ext}") if p.is_file()) 135 | if not files: 136 | print(f"No {in_ext} files found under {root}") 137 | return 0 138 | 139 | ok = 0 140 | fail = 0 141 | 142 | for in_file in files: 143 | out_file = in_file.with_suffix(out_ext) 144 | success = run_convert( 145 | ffmpeg_path=ffmpeg_path, 146 | in_file=in_file, 147 | out_file=out_file, 148 | overwrite=args.overwrite, 149 | dry_run=args.dry_run, 150 | verbose=args.verbose, 151 | ) 152 | if success: 153 | ok += 1 154 | else: 155 | fail += 1 156 | 157 | print(f"\nDone. OK={ok}, FAIL={fail}, TOTAL={ok+fail}") 158 | return 0 if fail == 0 else 1 159 | 160 | 161 | if __name__ == "__main__": 162 | raise SystemExit(main()) 163 | -------------------------------------------------------------------------------- /UNSHACKLE_README.md: -------------------------------------------------------------------------------- 1 |

2 | no_encryption unshackle 3 |
4 | Movie, TV, and Music Archival Software 5 |
6 | 7 | Discord 8 | 9 |

10 | 11 | ## What is unshackle? 12 | 13 | unshackle is a fork of [Devine](https://github.com/devine-dl/devine/), a powerful archival tool for downloading movies, TV shows, and music from streaming services. Built with a focus on modularity and extensibility, it provides a robust framework for content acquisition with support for DRM-protected content. 14 | 15 | ## Key Features 16 | 17 | - 🚀 **Easy Installation** - Simple UV installation 18 | - 🎥 **Multi-Media Support** - Movies, TV episodes, and music 19 | - 🛠️ **Built-in Parsers** - DASH/HLS and ISM manifest support 20 | - 🔒 **DRM Support** - Widevine and PlayReady integration 21 | - 🌈 **HDR10+DV Hybrid** - Hybrid Dolby Vision injection via [dovi_tool](https://github.com/quietvoid/dovi_tool) 22 | - 💾 **Flexible Storage** - Local and remote key vaults 23 | - 👥 **Multi-Profile Auth** - Support for cookies and credentials 24 | - 🤖 **Smart Naming** - Automatic P2P-style filename structure 25 | - ⚙️ **Configurable** - YAML-based configuration 26 | - ❤️ **Open Source** - Fully open-source with community contributions welcome 27 | 28 | ## Quick Start 29 | 30 | ### Installation 31 | 32 | This installs the latest version directly from the GitHub repository: 33 | 34 | ```shell 35 | git clone https://github.com/unshackle-dl/unshackle.git 36 | cd unshackle 37 | uv sync 38 | uv run unshackle --help 39 | ``` 40 | 41 | ### Install unshackle as a global (per-user) tool 42 | 43 | ```bash 44 | uv tool install git+https://github.com/unshackle-dl/unshackle.git 45 | # Then run: 46 | uvx unshackle --help # or just `unshackle` once PATH updated 47 | ``` 48 | 49 | > [!NOTE] 50 | > After installation, you may need to add the installation path to your PATH environment variable if prompted. 51 | 52 | > **Recommended:** Use `uv run unshackle` instead of direct command execution to ensure proper virtual environment activation. 53 | 54 | ## Planned Features 55 | 56 | - 🖥️ **Web UI Access & Control** - Manage and control unshackle from a modern web interface. 57 | - 🔄 **Sonarr/Radarr Interactivity** - Direct integration for automated personal downloads. 58 | - ⚙️ **Better ISM Support** - Improve on ISM support for multiple services 59 | - 🔉 **ATMOS** - Better Atmos Support/Selection 60 | - 🎵 **Music** - Cleanup Audio Tagging using the [tags.py](unshackle/core/utils/tags.py) for artist/track name etc. 61 | 62 | ### Basic Usage 63 | 64 | ```shell 65 | # Check available commands 66 | uv run unshackle --help 67 | 68 | # Configure your settings 69 | git clone https://github.com/unshackle-dl/unshackle.git 70 | cd unshackle 71 | uv sync 72 | uv run unshackle --help 73 | 74 | # Download content (requires configured services) 75 | uv run unshackle dl SERVICE_NAME CONTENT_ID 76 | ``` 77 | 78 | ## Documentation 79 | 80 | For comprehensive setup guides, configuration options, and advanced usage: 81 | 82 | 📖 **[Visit our WIKI](https://github.com/unshackle-dl/unshackle/wiki)** 83 | 84 | The WIKI contains detailed information on: 85 | 86 | - Service configuration 87 | - DRM configuration 88 | - Advanced features and troubleshooting 89 | 90 | For guidance on creating services, see our [WIKI documentation](https://github.com/unshackle-dl/unshackle/wiki). 91 | 92 | ## End User License Agreement 93 | 94 | unshackle and it's community pages should be treated with the same kindness as other projects. 95 | Please refrain from spam or asking for questions that infringe upon a Service's End User License Agreement. 96 | 97 | 1. Do not use unshackle for any purposes of which you do not have the rights to do so. 98 | 2. Do not share or request infringing content; this includes widevine Provision Keys, Content Encryption Keys, 99 | or Service API Calls or Code. 100 | 3. The Core codebase is meant to stay Free and Open-Source while the Service code should be kept private. 101 | 4. Do not sell any part of this project, neither alone nor as part of a bundle. 102 | If you paid for this software or received it as part of a bundle following payment, you should demand your money 103 | back immediately. 104 | 5. Be kind to one another and do not single anyone out. 105 | 106 | ## Licensing 107 | 108 | This software is licensed under the terms of [GNU General Public License, Version 3.0](LICENSE). 109 | You can find a copy of the license in the LICENSE file in the root folder. 110 | -------------------------------------------------------------------------------- /unshackle/services/iP/config.yaml: -------------------------------------------------------------------------------- 1 | base_url: https://www.bbc.co.uk/iplayer/{type}/{pid} 2 | user_agent: smarttv_AFTMM_Build_0003255372676_Chromium_41.0.2250.2 3 | api_key: D2FgtcTxGqqIgLsfBWTJdrQh2tVdeaAp 4 | 5 | endpoints: 6 | episodes: https://ibl.api.bbci.co.uk/ibl/v1/episodes/{pid}?rights=mobile&availability=available 7 | metadata: https://graph.ibl.api.bbc.co.uk/ 8 | playlist: https://www.bbc.co.uk/programmes/{pid}/playlist.json 9 | open: https://{}/mediaselector/6/select/version/2.0/mediaset/{}/vpid/{}/ 10 | secure: https://{}/mediaselector/6/select/version/2.0/vpid/{}/format/json/mediaset/{}/proto/https 11 | search: https://ibl.api.bbc.co.uk/ibl/v1/new-search 12 | 13 | certificate: | 14 | LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlFT3pDQ0F5T2dBd0lCQWdJQkFUQU5CZ2txaGtpRzl3MEJBUVVGQURDQm96RU 15 | xNQWtHQTFVRUJoTUNWVk14DQpFekFSQmdOVkJBZ1RDa05oYkdsbWIzSnVhV0V4RWpBUUJnTlZCQWNUQ1VOMWNHVnlkR2x1YnpFZU1C 16 | d0dBMVVFDQpDeE1WVUhKdlpDQlNiMjkwSUVObGNuUnBabWxqWVhSbE1Sa3dGd1lEVlFRTEV4QkVhV2RwZEdGc0lGQnliMlIxDQpZM1 17 | J6TVE4d0RRWURWUVFLRXdaQmJXRjZiMjR4SHpBZEJnTlZCQU1URmtGdFlYcHZiaUJHYVhKbFZGWWdVbTl2DQpkRU5CTURFd0hoY05N 18 | VFF4TURFMU1EQTFPREkyV2hjTk16UXhNREV3TURBMU9ESTJXakNCbVRFTE1Ba0dBMVVFDQpCaE1DVlZNeEV6QVJCZ05WQkFnVENrTm 19 | hiR2xtYjNKdWFXRXhFakFRQmdOVkJBY1RDVU4xY0dWeWRHbHViekVkDQpNQnNHQTFVRUN4TVVSR1YySUZKdmIzUWdRMlZ5ZEdsbWFX 20 | TmhkR1V4R1RBWEJnTlZCQXNURUVScFoybDBZV3dnDQpVSEp2WkhWamRITXhEekFOQmdOVkJBb1RCa0Z0WVhwdmJqRVdNQlFHQTFVRU 21 | F4TU5SbWx5WlZSV1VISnZaREF3DQpNVENDQVNBd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dFTkFEQ0NBUWdDZ2dFQkFNRFZTNUwwVUR4 22 | WnMwNkpGMld2DQpuZE1KajdIVGRlSlg5b0ltWWg3aytNY0VENXZ5OTA2M0p5c3FkS0tsbzVJZERvY2tuczg0VEhWNlNCVkFBaTBEDQ 23 | p6cEI4dHRJNUFBM1l3djFZUDJiOThpQ3F2OWhQalZndE9nNHFvMXZkK0oxdFdISUh5ZkV6cWlPRXVXNTlVd2xoDQpVTmFvY3JtZGNx 24 | bGcyWmIyZ1VybTZ2dlZqUThZcjQzY29MNnBBMk5ESXNyT0Z4c0ZZaXdaVk12cDZqMlk4dnFrDQpFOHJ2Tm04c3JkY0FhZjRXdHBuYW 25 | gyZ3RBY3IrdTVYNExZdmEwTzZrNGhENEdnNHZQQ2xQZ0JXbDZFSHRBdnFDDQpGWm9KbDhMNTN2VVY1QWhMQjdKQk0wUTFXVERINWs4 26 | NWNYT2tFd042NDhuZ09hZUtPMGxqYndZVG52NHhDV2NlDQo2RXNDQVFPamdZTXdnWUF3SHdZRFZSMGpCQmd3Rm9BVVo2RFJJSlNLK2 27 | hmWCtHVnBycWlubGMraTVmZ3dIUVlEDQpWUjBPQkJZRUZOeUNPZkhja3Vpclp2QXF6TzBXbjZLTmtlR1BNQWtHQTFVZEV3UUNNQUF3 28 | RXdZRFZSMGxCQXd3DQpDZ1lJS3dZQkJRVUhBd0l3RVFZSllJWklBWWI0UWdFQkJBUURBZ2VBTUFzR0ExVWREd1FFQXdJSGdEQU5CZ2 29 | txDQpoa2lHOXcwQkFRVUZBQU9DQVFFQXZXUHd4b1VhV3IwV0tXRXhHdHpQOElGVUUrZis5SUZjSzNoWXl2QmxLOUxODQo3Ym9WZHhx 30 | dWJGeEgzMFNmOC90VnNYMUpBOUM3bnMzZ09jV2Z0dTEzeUtzK0RnZGhqdG5GVkgraW4zNkVpZEZBDQpRRzM1UE1PU0ltNGNaVXkwME 31 | 4xRXRwVGpGY2VBbmF1ZjVJTTZNZmRBWlQ0RXNsL09OUHp5VGJYdHRCVlpBQmsxDQpXV2VHMEcwNDdUVlV6M2Ira0dOVTNzZEs5Ri9o 32 | NmRiS3c0azdlZWJMZi9KNjZKSnlkQUhybFhJdVd6R2tDbjFqDQozNWdHRHlQajd5MDZWNXV6MlUzYjlMZTdZWENnNkJCanBRN0wrRW 33 | d3OVVsSmpoN1pRMXU2R2RCNUEwcGFWM0VQDQpQTk1KN2J6Rkl1cHozdklPdk5nUVV4ZWs1SUVIczZKeXdjNXByck5MS3c9PQ0KLS0t 34 | LS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ0KLS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tDQpNSUlFdlFJQkFEQU5CZ2txaGtpRzl3ME 35 | JBUUVGQUFTQ0JLY3dnZ1NqQWdFQUFvSUJBUURBMVV1UzlGQThXYk5PDQppUmRscjUzVENZK3gwM1hpVi9hQ0ptSWU1UGpIQkErYjh2 36 | ZE90eWNyS25TaXBhT1NIUTZISko3UE9FeDFla2dWDQpRQUl0QTg2UWZMYlNPUUFOMk1MOVdEOW0vZklncXIvWVQ0MVlMVG9PS3FOYj 37 | NmaWRiVmh5QjhueE02b2poTGx1DQpmVk1KWVZEV3FISzVuWEtwWU5tVzlvRks1dXI3MVkwUEdLK04zS0MrcVFOalF5TEt6aGNiQldJ 38 | c0dWVEw2ZW85DQptUEw2cEJQSzd6WnZMSzNYQUduK0ZyYVoyb2RvTFFISy9ydVYrQzJMMnREdXBPSVErQm9PTHp3cFQ0QVZwZWhCDQ 39 | o3UUw2Z2hXYUNaZkMrZDcxRmVRSVN3ZXlRVE5FTlZrd3grWlBPWEZ6cEJNRGV1UEo0RG1uaWp0SlkyOEdFNTcrDQpNUWxuSHVoTEFn 40 | RURBb0lCQVFDQWpqSmgrRFY5a1NJMFcyVHVkUlBpQmwvTDRrNlc1VThCYnV3VW1LWGFBclVTDQpvZm8wZWhvY3h2aHNibTBNRTE4RX 41 | d4U0tKWWhPVVlWamdBRnpWOThLL2M4MjBLcXo1ZGRUa0NwRXFVd1Z4eXFRDQpOUWpsYzN3SmNjSTlQcVcrU09XaFdvYWd6UndYcmRE 42 | MFU0eXc2NHM1eGFIUkU2SEdRSkVQVHdEY21mSDlOK0JXDQovdVU4YVc1QWZOcHhqRzduSGF0cmhJQjU1cDZuNHNFNUVoTjBnSk9WMD 43 | lmMEdOb1pQUVhiT1VVcEJWOU1jQ2FsDQpsK1VTalpBRmRIbUlqWFBwR1FEelJJWTViY1hVQzBZYlRwaytRSmhrZ1RjSW1LRFJmd0FC 44 | YXRIdnlMeDlpaVY1DQp0ZWZoV1hhaDE4STdkbUF3TmRTN0U4QlpoL3d5MlIwNXQ0RHppYjlyQW9HQkFPU25yZXAybk1VRVAyNXdSQW 45 | RBDQozWDUxenYwOFNLWkh6b0VuNExRS1krLzg5VFRGOHZWS2wwQjZLWWlaYW14aWJqU1RtaDRCWHI4ZndRaytiazFCDQpReEZ3ZHVG 46 | eTd1MU43d0hSNU45WEFpNEtuamgxQStHcW9SYjg4bk43b1htekM3cTZzdFZRUk9peDJlRVFJWTVvDQpiREZUellaRnloNGlMdkU0bj 47 | V1WnVHL1JBb0dCQU5mazdHMDhvYlpacmsxSXJIVXZSQmVENzZRNDlzQ0lSMGRBDQpIU0hCZjBadFBEMjdGSEZtamFDN0YwWkM2QXdU 48 | RnBNL0FNWDR4UlpqNnhGalltYnlENGN3MFpGZ08rb0pwZjFIDQpFajNHSHdMNHFZekJFUXdRTmswSk9GbE84cDdVMm1ZL2hEVXM3bG 49 | JQQm82YUo4VVpJMGs3SHhSOVRWYVhud0h1DQovaXhnRjlsYkFvR0JBSmh2eVViNXZkaXRmNTcxZ3ErQWs2bWozMU45aGNRdjN3REZR 50 | SGdHN1Vxb28zaUQ5MDR4DQp1aXI4RzdCbVJ2THNTWGhpWnI2cmxIOXFnTERVU1lqV0xMWksrZXVoOUo0ejlLdmhReitQVnNsY2FYcj 51 | RyVUVjDQphMlNvb2FKU2E2WjNYU2NuSWVPSzJKc2hPK3RnRmw3d1NDRGlpUVF1aHI3QmRLRFFhbWU3MEVxTEFvR0JBSS90DQo4dk45 52 | d1NRN3lZamJIYU4wMkErdFNtMTdUeXNGaE5vcXZoYUEvNFJJMHRQU0RhRHZDUlhTRDRRc21ySzNaR0lxDQpBSVA3TGc3dFIyRHM3RV 53 | NoWDY5MTRRdVZmVWF4R1ZPRXR0UFphZ0g3RzdNcllMSzFlWWl3MER1Sjl4U041dTdWDQpBczRkOURuZldiUm14UzRRd2pEU0ZMaFRp 54 | T1JsRkt2MHFYTHF1cERuQW9HQWVFa3J4SjhJaXdhVEhnWXltM21TDQprU2h5anNWK01tVkJsVHNRK0ZabjFTM3k0YVdxbERhNUtMZF 55 | QvWDEwQXg4NHNQTmVtQVFVMGV4YTN0OHM5bHdIDQorT3NEaktLb3hqQ1Q3S2wzckdQeUFISnJmVlZ5U2VFZVgrOERLZFZKcjByU1Bk 56 | Qkk4Y2tFQ3kzQXpsVmphK3d3DQpST0N0emMxVHVyeG5OQTVxV0QzbjNmND0NCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0NCg== 57 | -------------------------------------------------------------------------------- /post_processing/extract_mks_subs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Selecting -S (subtitles only) as a download option results in an mks file 4 | which needs convertion to something acceptable for adding to a video playback. 5 | This is a post processing routne that operated in teh root foler of any number of 6 | mks files. 7 | The sceipt will recursively extract subtitle tracks from .mks files using mkvextract. 8 | 9 | A CLI for example would mkvextract "Taggart S01E02.mks" tracks 0:tS01E02.srt but the script 10 | finds each title and run the CLI on it. 11 | 12 | 13 | Default behavior: 14 | - Find all *.mks under a given root 15 | - Extract track 0 to an .srt with the same base name, in the same folder 16 | e.g. "Taggart S01E02.mks" -> "Taggart S01E02.srt" 17 | 18 | Requires: 19 | - mkvextract (part of MKVToolNix) available on PATH 20 | """ 21 | 22 | from __future__ import annotations 23 | 24 | import argparse 25 | import shutil 26 | import subprocess 27 | import sys 28 | from pathlib import Path 29 | 30 | 31 | def parse_args() -> argparse.Namespace: 32 | p = argparse.ArgumentParser( 33 | description="Extract SRT subtitles from .mks files (recursively) using mkvextract." 34 | ) 35 | p.add_argument( 36 | "root", 37 | nargs="?", 38 | default=".", 39 | help="Root directory to scan (default: current directory).", 40 | ) 41 | p.add_argument( 42 | "--track", 43 | type=int, 44 | default=0, 45 | help="Track index to extract (default: 0).", 46 | ) 47 | p.add_argument( 48 | "--ext", 49 | default=".mks", 50 | help="Input extension to scan for (default: .mks).", 51 | ) 52 | p.add_argument( 53 | "--out-ext", 54 | default=".srt", 55 | help="Output extension to write (default: .srt).", 56 | ) 57 | p.add_argument( 58 | "--overwrite", 59 | action="store_true", 60 | help="Overwrite existing output files.", 61 | ) 62 | p.add_argument( 63 | "--dry-run", 64 | action="store_true", 65 | help="Print what would be done, but don't run mkvextract.", 66 | ) 67 | p.add_argument( 68 | "--verbose", 69 | action="store_true", 70 | help="Print mkvextract output for each file.", 71 | ) 72 | return p.parse_args() 73 | 74 | 75 | def run_extract( 76 | mkvextract_path: str, 77 | in_file: Path, 78 | out_file: Path, 79 | track: int, 80 | overwrite: bool, 81 | dry_run: bool, 82 | verbose: bool, 83 | ) -> bool: 84 | if out_file.exists() and not overwrite: 85 | print(f"SKIP (exists): {out_file}") 86 | return True 87 | 88 | cmd = [ 89 | mkvextract_path, 90 | str(in_file), 91 | "tracks", 92 | f"{track}:{out_file}", 93 | ] 94 | 95 | if dry_run: 96 | print("DRY:", " ".join(map(str, cmd))) 97 | return True 98 | 99 | # Ensure parent exists (it should, but just in case you change output logic later) 100 | out_file.parent.mkdir(parents=True, exist_ok=True) 101 | 102 | try: 103 | proc = subprocess.run( 104 | cmd, 105 | stdout=subprocess.PIPE, 106 | stderr=subprocess.STDOUT, 107 | text=True, 108 | check=False, 109 | ) 110 | except FileNotFoundError: 111 | print("ERROR: mkvextract not found (is MKVToolNix installed and on PATH?)", file=sys.stderr) 112 | return False 113 | 114 | if verbose and proc.stdout: 115 | print(proc.stdout.rstrip()) 116 | 117 | if proc.returncode == 0 and out_file.exists(): 118 | print(f"OK : {in_file.name} -> {out_file.name}") 119 | return True 120 | 121 | print(f"FAIL: {in_file}", file=sys.stderr) 122 | if proc.stdout: 123 | print(proc.stdout.rstrip(), file=sys.stderr) 124 | return False 125 | 126 | 127 | def main() -> int: 128 | args = parse_args() 129 | 130 | mkvextract_path = shutil.which("mkvextract") 131 | if not mkvextract_path: 132 | print("ERROR: mkvextract not found on PATH. Install MKVToolNix.", file=sys.stderr) 133 | return 2 134 | 135 | root = Path(args.root).expanduser().resolve() 136 | if not root.exists(): 137 | print(f"ERROR: Root path does not exist: {root}", file=sys.stderr) 138 | return 2 139 | 140 | in_ext = args.ext if args.ext.startswith(".") else f".{args.ext}" 141 | out_ext = args.out_ext if args.out_ext.startswith(".") else f".{args.out_ext}" 142 | 143 | files = sorted(p for p in root.rglob(f"*{in_ext}") if p.is_file()) 144 | if not files: 145 | print(f"No {in_ext} files found under {root}") 146 | return 0 147 | 148 | ok = 0 149 | fail = 0 150 | 151 | for in_file in files: 152 | out_file = in_file.with_suffix(out_ext) 153 | success = run_extract( 154 | mkvextract_path=mkvextract_path, 155 | in_file=in_file, 156 | out_file=out_file, 157 | track=args.track, 158 | overwrite=args.overwrite, 159 | dry_run=args.dry_run, 160 | verbose=args.verbose, 161 | ) 162 | if success: 163 | ok += 1 164 | else: 165 | fail += 1 166 | 167 | print(f"\nDone. OK={ok}, FAIL={fail}, TOTAL={ok+fail}") 168 | return 0 if fail == 0 else 1 169 | 170 | 171 | if __name__ == "__main__": 172 | raise SystemExit(main()) 173 | -------------------------------------------------------------------------------- /unshackle/services/PTHS/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from typing import Optional 4 | from http.cookiejar import CookieJar 5 | from langcodes import Language 6 | import click 7 | 8 | from unshackle.core.constants import AnyTrack 9 | from unshackle.core.credential import Credential 10 | from unshackle.core.manifests import DASH 11 | from unshackle.core.service import Service 12 | from unshackle.core.titles import Movie, Movies, Title_T, Titles_T 13 | from unshackle.core.tracks import Tracks 14 | 15 | 16 | class PTHS(Service): 17 | """ 18 | Service code for Pathé Thuis (pathe-thuis.nl) 19 | Version: 1.0.0 20 | 21 | Security: SD @ L3 (Widevine) 22 | FHD @ L1 23 | Authorization: Cookies or authentication token 24 | 25 | Supported: 26 | • Movies → https://www.pathe-thuis.nl/film/{id} 27 | 28 | Note: 29 | Pathé Thuis does not have episodic content, only movies. 30 | """ 31 | 32 | TITLE_RE = ( 33 | r"^(?:https?://(?:www\.)?pathe-thuis\.nl/film/)?(?P\d+)(?:/[^/]+)?$" 34 | ) 35 | GEOFENCE = ("NL",) 36 | NO_SUBTITLES = True 37 | 38 | @staticmethod 39 | @click.command(name="PTHS", short_help="https://www.pathe-thuis.nl") 40 | @click.argument("title", type=str) 41 | @click.pass_context 42 | def cli(ctx, **kwargs): 43 | return PTHS(ctx, **kwargs) 44 | 45 | def __init__(self, ctx, title: str): 46 | super().__init__(ctx) 47 | 48 | m = re.match(self.TITLE_RE, title) 49 | if not m: 50 | raise ValueError( 51 | f"Unsupported Pathé Thuis URL or ID: {title}\n" 52 | "Use e.g. https://www.pathe-thuis.nl/film/30591" 53 | ) 54 | 55 | self.movie_id = m.group("id") 56 | self.drm_token = None 57 | 58 | if self.config is None: 59 | raise EnvironmentError("Missing service config for Pathé Thuis.") 60 | 61 | def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: 62 | super().authenticate(cookies, credential) 63 | 64 | if not cookies: 65 | self.log.warning("No cookies provided, proceeding unauthenticated.") 66 | return 67 | 68 | token = next((c.value for c in cookies if c.name == "authenticationToken"), None) 69 | if not token: 70 | self.log.info("No authenticationToken cookie found, unauthenticated mode.") 71 | return 72 | 73 | self.session.headers.update({ 74 | "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0", 75 | "X-Pathe-Device-Identifier": "web-widevine-1", 76 | "X-Pathe-Auth-Session-Token": token, 77 | }) 78 | self.log.info("Authentication token successfully attached to session.") 79 | 80 | 81 | def get_titles(self) -> Titles_T: 82 | url = self.config["endpoints"]["metadata"].format(movie_id=self.movie_id) 83 | r = self.session.get(url) 84 | r.raise_for_status() 85 | data = r.json() 86 | 87 | movie = Movie( 88 | id_=str(data["id"]), 89 | service=self.__class__, 90 | name=data["name"], 91 | description=data.get("intro", ""), 92 | year=data.get("year"), 93 | language=Language.get(data.get("language", "en")), 94 | data=data, 95 | ) 96 | return Movies([movie]) 97 | 98 | 99 | def get_tracks(self, title: Title_T) -> Tracks: 100 | ticket_id = self._get_ticket_id(title) 101 | url = self.config["endpoints"]["ticket"].format(ticket_id=ticket_id) 102 | 103 | r = self.session.get(url) 104 | r.raise_for_status() 105 | data = r.json() 106 | stream = data["stream"] 107 | 108 | manifest_url = stream.get("url") or stream.get("drmurl") 109 | if not manifest_url: 110 | raise ValueError("No stream manifest URL found.") 111 | 112 | self.drm_token = stream["token"] 113 | self.license_url = stream["rawData"]["licenseserver"] 114 | 115 | tracks = DASH.from_url(manifest_url, session=self.session).to_tracks(language=title.language) 116 | 117 | return tracks 118 | 119 | 120 | def _get_ticket_id(self, title: Title_T) -> str: 121 | """Fetch the user's owned ticket ID if present.""" 122 | data = title.data 123 | for t in (data.get("tickets") or []): 124 | if t.get("playable") and str(t.get("movieId")) == str(self.movie_id): 125 | return str(t["id"]) 126 | raise ValueError("No valid ticket found for this movie. Ensure purchase or login.") 127 | 128 | 129 | def get_chapters(self, title: Title_T): 130 | return [] 131 | 132 | 133 | def get_widevine_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes: 134 | if not self.license_url or not self.drm_token: 135 | raise ValueError("Missing license URL or token.") 136 | 137 | headers = { 138 | "Content-Type": "application/octet-stream", 139 | "Authorization": f"Bearer {self.drm_token}", 140 | } 141 | 142 | params = {"custom_data": self.drm_token} 143 | 144 | r = self.session.post(self.license_url, params=params, data=challenge, headers=headers) 145 | r.raise_for_status() 146 | 147 | if not r.content: 148 | raise ValueError("Empty license response, likely invalid or expired token.") 149 | return r.content -------------------------------------------------------------------------------- /unshackle/core/titles/song.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import Any, Iterable, Optional, Union 3 | 4 | from langcodes import Language 5 | from pymediainfo import MediaInfo 6 | from rich.tree import Tree 7 | from sortedcontainers import SortedKeyList 8 | 9 | from unshackle.core.config import config 10 | from unshackle.core.constants import AUDIO_CODEC_MAP 11 | from unshackle.core.titles.title import Title 12 | from unshackle.core.utilities import sanitize_filename 13 | 14 | 15 | class Song(Title): 16 | def __init__( 17 | self, 18 | id_: Any, 19 | service: type, 20 | name: str, 21 | artist: str, 22 | album: str, 23 | track: int, 24 | disc: int, 25 | year: int, 26 | language: Optional[Union[str, Language]] = None, 27 | data: Optional[Any] = None, 28 | ) -> None: 29 | super().__init__(id_, service, language, data) 30 | 31 | if not name: 32 | raise ValueError("Song name must be provided") 33 | if not isinstance(name, str): 34 | raise TypeError(f"Expected name to be a str, not {name!r}") 35 | 36 | if not artist: 37 | raise ValueError("Song artist must be provided") 38 | if not isinstance(artist, str): 39 | raise TypeError(f"Expected artist to be a str, not {artist!r}") 40 | 41 | if not album: 42 | raise ValueError("Song album must be provided") 43 | if not isinstance(album, str): 44 | raise TypeError(f"Expected album to be a str, not {name!r}") 45 | 46 | if not track: 47 | raise ValueError("Song track must be provided") 48 | if not isinstance(track, int): 49 | raise TypeError(f"Expected track to be an int, not {track!r}") 50 | 51 | if not disc: 52 | raise ValueError("Song disc must be provided") 53 | if not isinstance(disc, int): 54 | raise TypeError(f"Expected disc to be an int, not {disc!r}") 55 | 56 | if not year: 57 | raise ValueError("Song year must be provided") 58 | if not isinstance(year, int): 59 | raise TypeError(f"Expected year to be an int, not {year!r}") 60 | 61 | name = name.strip() 62 | artist = artist.strip() 63 | album = album.strip() 64 | 65 | if track <= 0: 66 | raise ValueError(f"Song track cannot be {track}") 67 | if disc <= 0: 68 | raise ValueError(f"Song disc cannot be {disc}") 69 | if year <= 0: 70 | raise ValueError(f"Song year cannot be {year}") 71 | 72 | self.name = name 73 | self.artist = artist 74 | self.album = album 75 | self.track = track 76 | self.disc = disc 77 | self.year = year 78 | 79 | def __str__(self) -> str: 80 | return "{artist} - {album} ({year}) / {track:02}. {name}".format( 81 | artist=self.artist, album=self.album, year=self.year, track=self.track, name=self.name 82 | ).strip() 83 | 84 | def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: 85 | audio_track = next(iter(media_info.audio_tracks), None) 86 | codec = audio_track.format 87 | channel_layout = audio_track.channel_layout or audio_track.channellayout_original 88 | if channel_layout: 89 | channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))) 90 | else: 91 | channel_count = audio_track.channel_s or audio_track.channels or 0 92 | channels = float(channel_count) 93 | 94 | features = audio_track.format_additionalfeatures or "" 95 | 96 | if folder: 97 | # Artist - Album (Year) 98 | name = str(self).split(" / ")[0] 99 | else: 100 | # NN. Song Name 101 | name = str(self).split(" / ")[1] 102 | 103 | if config.scene_naming: 104 | # Service 105 | if show_service: 106 | name += f" {self.service.__name__}" 107 | 108 | # 'WEB-DL' 109 | name += " WEB-DL" 110 | 111 | # Audio Codec + Channels (+ feature) 112 | name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}" 113 | if "JOC" in features or audio_track.joc: 114 | name += " Atmos" 115 | 116 | if config.tag: 117 | name += f"-{config.tag}" 118 | 119 | return sanitize_filename(name, " ") 120 | else: 121 | # Simple naming style without technical details 122 | return sanitize_filename(name, " ") 123 | 124 | 125 | class Album(SortedKeyList, ABC): 126 | def __init__(self, iterable: Optional[Iterable] = None): 127 | super().__init__(iterable, key=lambda x: (x.album, x.disc, x.track, x.year or 0)) 128 | 129 | def __str__(self) -> str: 130 | if not self: 131 | return super().__str__() 132 | return f"{self[0].artist} - {self[0].album} ({self[0].year or '?'})" 133 | 134 | def tree(self, verbose: bool = False) -> Tree: 135 | num_songs = len(self) 136 | tree = Tree(f"{num_songs} Song{['s', ''][num_songs == 1]}", guide_style="bright_black") 137 | if verbose: 138 | for song in self: 139 | tree.add(f"[bold]Track {song.track:02}.[/] [bright_black]({song.name})", guide_style="bright_black") 140 | 141 | return tree 142 | 143 | 144 | __all__ = ("Song", "Album") 145 | -------------------------------------------------------------------------------- /unshackle/core/tracks/attachment.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import mimetypes 4 | import os 5 | from pathlib import Path 6 | from typing import Optional, Union 7 | from urllib.parse import urlparse 8 | from zlib import crc32 9 | 10 | import requests 11 | 12 | from unshackle.core.config import config 13 | 14 | 15 | class Attachment: 16 | def __init__( 17 | self, 18 | path: Union[Path, str, None] = None, 19 | url: Optional[str] = None, 20 | name: Optional[str] = None, 21 | mime_type: Optional[str] = None, 22 | description: Optional[str] = None, 23 | session: Optional[requests.Session] = None, 24 | ): 25 | """ 26 | Create a new Attachment. 27 | 28 | If providing a path, the file must already exist. 29 | If providing a URL, the file will be downloaded to the temp directory. 30 | Either path or url must be provided. 31 | 32 | If name is not provided it will use the file name (without extension). 33 | If mime_type is not provided, it will try to guess it. 34 | 35 | Args: 36 | path: Path to an existing file. 37 | url: URL to download the attachment from. 38 | name: Name of the attachment. 39 | mime_type: MIME type of the attachment. 40 | description: Description of the attachment. 41 | session: Optional requests session to use for downloading. 42 | """ 43 | if path is None and url is None: 44 | raise ValueError("Either path or url must be provided.") 45 | 46 | if url: 47 | if not isinstance(url, str): 48 | raise ValueError("The attachment URL must be a string.") 49 | 50 | # If a URL is provided, download the file to the temp directory 51 | parsed_url = urlparse(url) 52 | file_name = os.path.basename(parsed_url.path) or "attachment" 53 | 54 | # Use provided name for the file if available 55 | if name: 56 | file_name = f"{name.replace(' ', '_')}{os.path.splitext(file_name)[1]}" 57 | 58 | download_path = config.directories.temp / file_name 59 | 60 | # Download the file 61 | try: 62 | session = session or requests.Session() 63 | response = session.get(url, stream=True) 64 | response.raise_for_status() 65 | config.directories.temp.mkdir(parents=True, exist_ok=True) 66 | download_path.parent.mkdir(parents=True, exist_ok=True) 67 | 68 | with open(download_path, "wb") as f: 69 | for chunk in response.iter_content(chunk_size=8192): 70 | f.write(chunk) 71 | 72 | path = download_path 73 | except Exception as e: 74 | raise ValueError(f"Failed to download attachment from URL: {e}") 75 | 76 | if not isinstance(path, (str, Path)): 77 | raise ValueError("The attachment path must be provided.") 78 | 79 | path = Path(path) 80 | if not path.exists(): 81 | raise ValueError("The attachment file does not exist.") 82 | 83 | name = (name or path.stem).strip() 84 | mime_type = (mime_type or "").strip() or None 85 | description = (description or "").strip() or None 86 | 87 | if not mime_type: 88 | mime_type = { 89 | ".ttf": "application/x-truetype-font", 90 | ".otf": "application/vnd.ms-opentype", 91 | ".jpg": "image/jpeg", 92 | ".jpeg": "image/jpeg", 93 | ".png": "image/png", 94 | }.get(path.suffix.lower(), mimetypes.guess_type(path)[0]) 95 | if not mime_type: 96 | raise ValueError("The attachment mime-type could not be automatically detected.") 97 | 98 | self.path = path 99 | self.name = name 100 | self.mime_type = mime_type 101 | self.description = description 102 | 103 | def __repr__(self) -> str: 104 | return "{name}({items})".format( 105 | name=self.__class__.__name__, items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()]) 106 | ) 107 | 108 | def __str__(self) -> str: 109 | return " | ".join(filter(bool, ["ATT", self.name, self.mime_type, self.description])) 110 | 111 | @property 112 | def id(self) -> str: 113 | """Compute an ID from the attachment data.""" 114 | checksum = crc32(self.path.read_bytes()) 115 | return hex(checksum) 116 | 117 | def delete(self) -> None: 118 | if self.path: 119 | self.path.unlink() 120 | self.path = None 121 | 122 | @classmethod 123 | def from_url( 124 | cls, 125 | url: str, 126 | name: Optional[str] = None, 127 | mime_type: Optional[str] = None, 128 | description: Optional[str] = None, 129 | session: Optional[requests.Session] = None, 130 | ) -> "Attachment": 131 | """ 132 | Create an attachment from a URL. 133 | 134 | Args: 135 | url: URL to download the attachment from. 136 | name: Name of the attachment. 137 | mime_type: MIME type of the attachment. 138 | description: Description of the attachment. 139 | session: Optional requests session to use for downloading. 140 | 141 | Returns: 142 | Attachment: A new attachment instance. 143 | """ 144 | return cls(url=url, name=name, mime_type=mime_type, description=description, session=session) 145 | 146 | 147 | __all__ = ("Attachment",) 148 | -------------------------------------------------------------------------------- /unshackle/core/tracks/chapters.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from abc import ABC 5 | from pathlib import Path 6 | from typing import Any, Iterable, Optional, Union 7 | from zlib import crc32 8 | 9 | from sortedcontainers import SortedKeyList 10 | 11 | from unshackle.core.tracks import Chapter 12 | 13 | OGM_SIMPLE_LINE_1_FORMAT = re.compile(r"^CHAPTER(?P\d+)=(?P\d{2,}:\d{2}:\d{2}\.\d{3})$") 14 | OGM_SIMPLE_LINE_2_FORMAT = re.compile(r"^CHAPTER(?P\d+)NAME=(?P.*)$") 15 | 16 | 17 | class Chapters(SortedKeyList, ABC): 18 | def __init__(self, iterable: Optional[Iterable[Chapter]] = None): 19 | super().__init__(key=lambda x: x.timestamp or 0) 20 | for chapter in iterable or []: 21 | self.add(chapter) 22 | 23 | def __repr__(self) -> str: 24 | return "{name}({items})".format( 25 | name=self.__class__.__name__, items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()]) 26 | ) 27 | 28 | def __str__(self) -> str: 29 | return "\n".join( 30 | [ 31 | " | ".join(filter(bool, ["CHP", f"[{i:02}]", chapter.timestamp, chapter.name])) 32 | for i, chapter in enumerate(self, start=1) 33 | ] 34 | ) 35 | 36 | @classmethod 37 | def loads(cls, data: str) -> Chapters: 38 | """Load chapter data from a string.""" 39 | lines = [line.strip() for line in data.strip().splitlines(keepends=False)] 40 | 41 | if len(lines) % 2 != 0: 42 | raise ValueError("The number of chapter lines must be even.") 43 | 44 | chapters = [] 45 | 46 | for line_1, line_2 in zip(lines[::2], lines[1::2]): 47 | line_1_match = OGM_SIMPLE_LINE_1_FORMAT.match(line_1) 48 | if not line_1_match: 49 | raise SyntaxError(f"An unexpected syntax error occurred on: {line_1}") 50 | line_2_match = OGM_SIMPLE_LINE_2_FORMAT.match(line_2) 51 | if not line_2_match: 52 | raise SyntaxError(f"An unexpected syntax error occurred on: {line_2}") 53 | 54 | line_1_number, timestamp = line_1_match.groups() 55 | line_2_number, name = line_2_match.groups() 56 | 57 | if line_1_number != line_2_number: 58 | raise SyntaxError( 59 | f"The chapter numbers {line_1_number} and {line_2_number} do not match on:\n{line_1}\n{line_2}" 60 | ) 61 | 62 | if not timestamp: 63 | raise SyntaxError(f"The timestamp is missing on: {line_1}") 64 | 65 | chapters.append(Chapter(timestamp, name)) 66 | 67 | return cls(chapters) 68 | 69 | @classmethod 70 | def load(cls, path: Union[Path, str]) -> Chapters: 71 | """Load chapter data from a file.""" 72 | if isinstance(path, str): 73 | path = Path(path) 74 | return cls.loads(path.read_text(encoding="utf8")) 75 | 76 | def dumps(self, fallback_name: str = "") -> str: 77 | """ 78 | Return chapter data in OGM-based Simple Chapter format. 79 | https://mkvtoolnix.download/doc/mkvmerge.html#mkvmerge.chapters.simple 80 | 81 | Parameters: 82 | fallback_name: Name used for Chapters without a Name set. 83 | 84 | The fallback name can use the following variables in f-string style: 85 | 86 | - {i}: The Chapter number starting at 1. 87 | E.g., `"Chapter {i}"`: "Chapter 1", "Intro", "Chapter 3". 88 | - {j}: A number starting at 1 that increments any time a Chapter has no name. 89 | E.g., `"Chapter {j}"`: "Chapter 1", "Intro", "Chapter 2". 90 | 91 | These are formatted with f-strings, directives are supported. 92 | For example, `"Chapter {i:02}"` will result in `"Chapter 01"`. 93 | """ 94 | chapters = [] 95 | j = 0 96 | 97 | for i, chapter in enumerate(self, start=1): 98 | if not chapter.name: 99 | j += 1 100 | chapters.append( 101 | "CHAPTER{num}={time}\nCHAPTER{num}NAME={name}".format( 102 | num=f"{i:02}", time=chapter.timestamp, name=chapter.name or fallback_name.format(i=i, j=j) 103 | ) 104 | ) 105 | 106 | return "\n".join(chapters) 107 | 108 | def dump(self, path: Union[Path, str], *args: Any, **kwargs: Any) -> int: 109 | """ 110 | Write chapter data in OGM-based Simple Chapter format to a file. 111 | 112 | Parameters: 113 | path: The file path to write the Chapter data to, overwriting 114 | any existing data. 115 | 116 | See `Chapters.dumps` for more parameter documentation. 117 | """ 118 | if isinstance(path, str): 119 | path = Path(path) 120 | path.parent.mkdir(parents=True, exist_ok=True) 121 | 122 | ogm_text = self.dumps(*args, **kwargs) 123 | return path.write_text(ogm_text, encoding="utf8") 124 | 125 | def add(self, value: Chapter) -> None: 126 | if not isinstance(value, Chapter): 127 | raise TypeError(f"Can only add {Chapter} objects, not {type(value)}") 128 | 129 | if any(chapter.timestamp == value.timestamp for chapter in self): 130 | raise ValueError(f"A Chapter with the Timestamp {value.timestamp} already exists") 131 | 132 | super().add(value) 133 | 134 | if not any(chapter.timestamp == "00:00:00.000" for chapter in self): 135 | self.add(Chapter(0)) 136 | 137 | @property 138 | def id(self) -> str: 139 | """Compute an ID from the Chapter data.""" 140 | checksum = crc32("\n".join([chapter.id for chapter in self]).encode("utf8")) 141 | return hex(checksum) 142 | 143 | 144 | __all__ = ("Chapters", "Chapter") 145 | -------------------------------------------------------------------------------- /unshackle/commands/serve.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | 4 | import click 5 | from aiohttp import web 6 | 7 | from unshackle.core import binaries 8 | from unshackle.core.api import cors_middleware, setup_routes, setup_swagger 9 | from unshackle.core.config import config 10 | from unshackle.core.constants import context_settings 11 | 12 | 13 | @click.command( 14 | short_help="Serve your Local Widevine Devices and REST API for Remote Access.", context_settings=context_settings 15 | ) 16 | @click.option("-h", "--host", type=str, default="0.0.0.0", help="Host to serve from.") 17 | @click.option("-p", "--port", type=int, default=8786, help="Port to serve from.") 18 | @click.option("--caddy", is_flag=True, default=False, help="Also serve with Caddy.") 19 | @click.option("--api-only", is_flag=True, default=False, help="Serve only the REST API, not pywidevine CDM.") 20 | @click.option("--no-key", is_flag=True, default=False, help="Disable API key authentication (allows all requests).") 21 | @click.option( 22 | "--debug-api", 23 | is_flag=True, 24 | default=False, 25 | help="Include technical debug information (tracebacks, stderr) in API error responses.", 26 | ) 27 | def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug_api: bool) -> None: 28 | """ 29 | Serve your Local Widevine Devices and REST API for Remote Access. 30 | 31 | \b 32 | Host as 127.0.0.1 may block remote access even if port-forwarded. 33 | Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded. 34 | 35 | \b 36 | You may serve with Caddy at the same time with --caddy. You can use Caddy 37 | as a reverse-proxy to serve with HTTPS. The config used will be the Caddyfile 38 | next to the unshackle config. 39 | 40 | \b 41 | The REST API provides programmatic access to unshackle functionality. 42 | Configure authentication in your config under serve.users and serve.api_secret. 43 | """ 44 | from pywidevine import serve as pywidevine_serve 45 | 46 | log = logging.getLogger("serve") 47 | 48 | # Validate API secret for REST API routes (unless --no-key is used) 49 | if not no_key: 50 | api_secret = config.serve.get("api_secret") 51 | if not api_secret: 52 | raise click.ClickException( 53 | "API secret key is not configured. Please add 'api_secret' to the 'serve' section in your config." 54 | ) 55 | else: 56 | api_secret = None 57 | log.warning("Running with --no-key: Authentication is DISABLED for all API endpoints!") 58 | 59 | if debug_api: 60 | log.warning("Running with --debug-api: Error responses will include technical debug information!") 61 | 62 | if caddy: 63 | if not binaries.Caddy: 64 | raise click.ClickException('Caddy executable "caddy" not found but is required for --caddy.') 65 | caddy_p = subprocess.Popen( 66 | [binaries.Caddy, "run", "--config", str(config.directories.user_configs / "Caddyfile")] 67 | ) 68 | else: 69 | caddy_p = None 70 | 71 | try: 72 | if not config.serve.get("devices"): 73 | config.serve["devices"] = [] 74 | config.serve["devices"].extend(list(config.directories.wvds.glob("*.wvd"))) 75 | 76 | if api_only: 77 | # API-only mode: serve just the REST API 78 | log.info("Starting REST API server (pywidevine CDM disabled)") 79 | if no_key: 80 | app = web.Application(middlewares=[cors_middleware]) 81 | app["config"] = {"users": []} 82 | else: 83 | app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication]) 84 | app["config"] = {"users": [api_secret]} 85 | app["debug_api"] = debug_api 86 | setup_routes(app) 87 | setup_swagger(app) 88 | log.info(f"REST API endpoints available at http://{host}:{port}/api/") 89 | log.info(f"Swagger UI available at http://{host}:{port}/api/docs/") 90 | log.info("(Press CTRL+C to quit)") 91 | web.run_app(app, host=host, port=port, print=None) 92 | else: 93 | # Integrated mode: serve both pywidevine + REST API 94 | log.info("Starting integrated server (pywidevine CDM + REST API)") 95 | 96 | # Create integrated app with both pywidevine and API routes 97 | if no_key: 98 | app = web.Application(middlewares=[cors_middleware]) 99 | app["config"] = dict(config.serve) 100 | app["config"]["users"] = [] 101 | else: 102 | app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication]) 103 | # Setup config - add API secret to users for authentication 104 | serve_config = dict(config.serve) 105 | if not serve_config.get("users"): 106 | serve_config["users"] = [] 107 | if api_secret not in serve_config["users"]: 108 | serve_config["users"].append(api_secret) 109 | app["config"] = serve_config 110 | 111 | app.on_startup.append(pywidevine_serve._startup) 112 | app.on_cleanup.append(pywidevine_serve._cleanup) 113 | app.add_routes(pywidevine_serve.routes) 114 | app["debug_api"] = debug_api 115 | setup_routes(app) 116 | setup_swagger(app) 117 | 118 | log.info(f"REST API endpoints available at http://{host}:{port}/api/") 119 | log.info(f"Swagger UI available at http://{host}:{port}/api/docs/") 120 | log.info("(Press CTRL+C to quit)") 121 | web.run_app(app, host=host, port=port, print=None) 122 | finally: 123 | if caddy_p: 124 | caddy_p.kill() 125 | -------------------------------------------------------------------------------- /unshackle/core/proxies/surfsharkvpn.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import re 4 | from typing import Optional 5 | 6 | import requests 7 | 8 | from unshackle.core.proxies.proxy import Proxy 9 | 10 | 11 | class SurfsharkVPN(Proxy): 12 | def __init__(self, username: str, password: str, server_map: Optional[dict[str, int]] = None): 13 | """ 14 | Proxy Service using SurfsharkVPN Service Credentials. 15 | 16 | A username and password must be provided. These are Service Credentials, not your Login Credentials. 17 | The Service Credentials can be found here: https://my.surfshark.com/vpn/manual-setup/main/openvpn 18 | """ 19 | if not username: 20 | raise ValueError("No Username was provided to the SurfsharkVPN Proxy Service.") 21 | if not password: 22 | raise ValueError("No Password was provided to the SurfsharkVPN Proxy Service.") 23 | if not re.match(r"^[a-z0-9]{48}$", username + password, re.IGNORECASE) or "@" in username: 24 | raise ValueError( 25 | "The Username and Password must be SurfsharkVPN Service Credentials, not your Login Credentials. " 26 | "The Service Credentials can be found here: https://my.surfshark.com/vpn/manual-setup/main/openvpn" 27 | ) 28 | 29 | if server_map is not None and not isinstance(server_map, dict): 30 | raise TypeError(f"Expected server_map to be a dict mapping a region to a server ID, not '{server_map!r}'.") 31 | 32 | self.username = username 33 | self.password = password 34 | self.server_map = server_map or {} 35 | 36 | self.countries = self.get_countries() 37 | 38 | def __repr__(self) -> str: 39 | countries = len(set(x.get("country") for x in self.countries if x.get("country"))) 40 | servers = sum(1 for x in self.countries if x.get("connectionName")) 41 | 42 | return f"{countries} Countr{['ies', 'y'][countries == 1]} ({servers} Server{['s', ''][servers == 1]})" 43 | 44 | def get_proxy(self, query: str) -> Optional[str]: 45 | """ 46 | Get an HTTP(SSL) proxy URI for a SurfsharkVPN server. 47 | """ 48 | query = query.lower() 49 | if re.match(r"^[a-z]{2}\d+$", query): 50 | # country and surfsharkvpn server id, e.g., au-per, be-anr, us-bos 51 | hostname = f"{query}.prod.surfshark.com" 52 | else: 53 | if query.isdigit(): 54 | # country id 55 | country = self.get_country(by_id=int(query)) 56 | elif re.match(r"^[a-z]+$", query): 57 | # country code 58 | country = self.get_country(by_code=query) 59 | else: 60 | raise ValueError(f"The query provided is unsupported and unrecognized: {query}") 61 | if not country: 62 | # SurfsharkVPN doesnt have servers in this region 63 | return 64 | 65 | server_mapping = self.server_map.get(country["countryCode"].lower()) 66 | if server_mapping: 67 | # country was set to a specific server ID in config 68 | hostname = f"{country['code'].lower()}{server_mapping}.prod.surfshark.com" 69 | else: 70 | # get the random server ID 71 | random_server = self.get_random_server(country["countryCode"]) 72 | if not random_server: 73 | raise ValueError( 74 | f"The SurfsharkVPN Country {query} currently has no random servers. " 75 | "Try again later. If the issue persists, double-check the query." 76 | ) 77 | hostname = random_server 78 | 79 | return f"https://{self.username}:{self.password}@{hostname}:443" 80 | 81 | def get_country(self, by_id: Optional[int] = None, by_code: Optional[str] = None) -> Optional[dict]: 82 | """Search for a Country and it's metadata.""" 83 | if all(x is None for x in (by_id, by_code)): 84 | raise ValueError("At least one search query must be made.") 85 | 86 | for country in self.countries: 87 | if all( 88 | [ 89 | by_id is None or country["id"] == int(by_id), 90 | by_code is None or country["countryCode"] == by_code.upper(), 91 | ] 92 | ): 93 | return country 94 | 95 | def get_random_server(self, country_id: str): 96 | """ 97 | Get the list of random Server for a Country. 98 | 99 | Note: There may not always be more than one recommended server. 100 | """ 101 | country = [x["connectionName"] for x in self.countries if x["countryCode"].lower() == country_id.lower()] 102 | try: 103 | country = random.choice(country) 104 | return country 105 | except Exception: 106 | raise ValueError("Could not get random countrycode from the countries list.") 107 | 108 | @staticmethod 109 | def get_countries() -> list[dict]: 110 | """Get a list of available Countries and their metadata.""" 111 | res = requests.get( 112 | url="https://api.surfshark.com/v3/server/clusters/all", 113 | headers={ 114 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", 115 | "Content-Type": "application/json", 116 | }, 117 | ) 118 | if not res.ok: 119 | raise ValueError(f"Failed to get a list of SurfsharkVPN countries [{res.status_code}]") 120 | 121 | try: 122 | return res.json() 123 | except json.JSONDecodeError: 124 | raise ValueError("Could not decode list of SurfsharkVPN countries, not JSON data.") 125 | -------------------------------------------------------------------------------- /unshackle/core/proxies/nordvpn.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from typing import Optional 4 | 5 | import requests 6 | 7 | from unshackle.core.proxies.proxy import Proxy 8 | 9 | 10 | class NordVPN(Proxy): 11 | def __init__(self, username: str, password: str, server_map: Optional[dict[str, int]] = None): 12 | """ 13 | Proxy Service using NordVPN Service Credentials. 14 | 15 | A username and password must be provided. These are Service Credentials, not your Login Credentials. 16 | The Service Credentials can be found here: https://my.nordaccount.com/dashboard/nordvpn/ 17 | """ 18 | if not username: 19 | raise ValueError("No Username was provided to the NordVPN Proxy Service.") 20 | if not password: 21 | raise ValueError("No Password was provided to the NordVPN Proxy Service.") 22 | if not re.match(r"^[a-z0-9]{48}$", username + password, re.IGNORECASE) or "@" in username: 23 | raise ValueError( 24 | "The Username and Password must be NordVPN Service Credentials, not your Login Credentials. " 25 | "The Service Credentials can be found here: https://my.nordaccount.com/dashboard/nordvpn/" 26 | ) 27 | 28 | if server_map is not None and not isinstance(server_map, dict): 29 | raise TypeError(f"Expected server_map to be a dict mapping a region to a server ID, not '{server_map!r}'.") 30 | 31 | self.username = username 32 | self.password = password 33 | self.server_map = server_map or {} 34 | 35 | self.countries = self.get_countries() 36 | 37 | def __repr__(self) -> str: 38 | countries = len(self.countries) 39 | servers = sum(x["serverCount"] for x in self.countries) 40 | 41 | return f"{countries} Countr{['ies', 'y'][countries == 1]} ({servers} Server{['s', ''][servers == 1]})" 42 | 43 | def get_proxy(self, query: str) -> Optional[str]: 44 | """ 45 | Get an HTTP(SSL) proxy URI for a NordVPN server. 46 | 47 | HTTP proxies under port 80 were disabled on the 15th of Feb, 2021: 48 | https://nordvpn.com/blog/removing-http-proxies 49 | """ 50 | query = query.lower() 51 | if re.match(r"^[a-z]{2}\d+$", query): 52 | # country and nordvpn server id, e.g., us1, fr1234 53 | hostname = f"{query}.nordvpn.com" 54 | else: 55 | if query.isdigit(): 56 | # country id 57 | country = self.get_country(by_id=int(query)) 58 | elif re.match(r"^[a-z]+$", query): 59 | # country code 60 | country = self.get_country(by_code=query) 61 | else: 62 | raise ValueError(f"The query provided is unsupported and unrecognized: {query}") 63 | if not country: 64 | # NordVPN doesnt have servers in this region 65 | return 66 | 67 | server_mapping = self.server_map.get(country["code"].lower()) 68 | if server_mapping: 69 | # country was set to a specific server ID in config 70 | hostname = f"{country['code'].lower()}{server_mapping}.nordvpn.com" 71 | else: 72 | # get the recommended server ID 73 | recommended_servers = self.get_recommended_servers(country["id"]) 74 | if not recommended_servers: 75 | raise ValueError( 76 | f"The NordVPN Country {query} currently has no recommended servers. " 77 | "Try again later. If the issue persists, double-check the query." 78 | ) 79 | hostname = recommended_servers[0]["hostname"] 80 | 81 | if hostname.startswith("gb"): 82 | # NordVPN uses the alpha2 of 'GB' in API responses, but 'UK' in the hostname 83 | hostname = f"gb{hostname[2:]}" 84 | 85 | return f"https://{self.username}:{self.password}@{hostname}:89" 86 | 87 | def get_country(self, by_id: Optional[int] = None, by_code: Optional[str] = None) -> Optional[dict]: 88 | """Search for a Country and it's metadata.""" 89 | if all(x is None for x in (by_id, by_code)): 90 | raise ValueError("At least one search query must be made.") 91 | 92 | for country in self.countries: 93 | if all( 94 | [by_id is None or country["id"] == int(by_id), by_code is None or country["code"] == by_code.upper()] 95 | ): 96 | return country 97 | 98 | @staticmethod 99 | def get_recommended_servers(country_id: int) -> list[dict]: 100 | """ 101 | Get the list of recommended Servers for a Country. 102 | 103 | Note: There may not always be more than one recommended server. 104 | """ 105 | res = requests.get( 106 | url="https://api.nordvpn.com/v1/servers/recommendations", params={"filters[country_id]": country_id} 107 | ) 108 | if not res.ok: 109 | raise ValueError(f"Failed to get a list of NordVPN countries [{res.status_code}]") 110 | 111 | try: 112 | return res.json() 113 | except json.JSONDecodeError: 114 | raise ValueError("Could not decode list of NordVPN countries, not JSON data.") 115 | 116 | @staticmethod 117 | def get_countries() -> list[dict]: 118 | """Get a list of available Countries and their metadata.""" 119 | res = requests.get( 120 | url="https://api.nordvpn.com/v1/servers/countries", 121 | ) 122 | if not res.ok: 123 | raise ValueError(f"Failed to get a list of NordVPN countries [{res.status_code}]") 124 | 125 | try: 126 | return res.json() 127 | except json.JSONDecodeError: 128 | raise ValueError("Could not decode list of NordVPN countries, not JSON data.") 129 | -------------------------------------------------------------------------------- /unshackle/services/ARD/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from http.cookiejar import MozillaCookieJar 4 | from typing import Any, Optional, Union 5 | from functools import partial 6 | from pathlib import Path 7 | import sys 8 | import re 9 | 10 | import click 11 | import webvtt 12 | import requests 13 | from click import Context 14 | from bs4 import BeautifulSoup 15 | 16 | from unshackle.core.credential import Credential 17 | from unshackle.core.service import Service 18 | from unshackle.core.titles import Movie, Movies, Episode, Series 19 | from unshackle.core.tracks import Track, Chapter, Tracks, Video, Subtitle 20 | from unshackle.core.manifests.hls import HLS 21 | from unshackle.core.manifests.dash import DASH 22 | 23 | 24 | class ARD(Service): 25 | """ 26 | Service code for ARD Mediathek (https://www.ardmediathek.de) 27 | 28 | \b 29 | Version: 1.0.0 30 | Author: lambda 31 | Authorization: None 32 | Robustness: 33 | Unencrypted: 2160p, AAC2.0 34 | """ 35 | 36 | GEOFENCE = ("de",) 37 | TITLE_RE = r"^(https://www\.ardmediathek\.de/(?Pserie|video)/.+/)(?P[a-zA-Z0-9]{10,})(/[0-9]{1,3})?$" 38 | EPISODE_NAME_RE = r"^(Folge [0-9]+:)?(?P[^\(]+) \(S(?P[0-9]+)/E(?P[0-9]+)\)$" 39 | 40 | @staticmethod 41 | @click.command(name="ARD", short_help="https://www.ardmediathek.de", help=__doc__) 42 | @click.argument("title", type=str) 43 | @click.pass_context 44 | def cli(ctx: Context, **kwargs: Any) -> ARD: 45 | return ARD(ctx, **kwargs) 46 | 47 | def __init__(self, ctx: Context, title: str): 48 | self.title = title 49 | super().__init__(ctx) 50 | 51 | def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None: 52 | pass 53 | 54 | def get_titles(self) -> Union[Movies, Series]: 55 | match = re.match(self.TITLE_RE, self.title) 56 | if not match: 57 | return 58 | 59 | item_id = match.group("item_id") 60 | if match.group("item_type") == "video": 61 | return self.load_player(item_id) 62 | 63 | r = self.session.get(self.config["endpoints"]["grouping"].format(item_id=item_id)) 64 | item = r.json() 65 | 66 | for widget in item["widgets"]: 67 | if widget["type"] == "gridlist" and widget.get("compilationType") == "itemsOfShow": 68 | episodes = Series() 69 | for teaser in widget["teasers"]: 70 | if teaser["coreAssetType"] != "EPISODE": 71 | continue 72 | 73 | if 'Hörfassung' in teaser['longTitle']: 74 | continue 75 | 76 | episodes += self.load_player(teaser["id"]) 77 | return episodes 78 | 79 | def get_tracks(self, title: Union[Episode, Movie]) -> Tracks: 80 | if title.data["blockedByFsk"]: 81 | self.log.error( 82 | "This content is age-restricted and not currently available. " 83 | "Try again after 10pm German time") 84 | sys.exit(0) 85 | 86 | media_collection = title.data["mediaCollection"]["embedded"] 87 | tracks = Tracks() 88 | for stream_collection in media_collection["streams"]: 89 | if stream_collection["kind"] != "main": 90 | continue 91 | 92 | for stream in stream_collection["media"]: 93 | if stream["mimeType"] == "application/vnd.apple.mpegurl": 94 | tracks += Tracks(HLS.from_url(stream["url"]).to_tracks(stream["audios"][0]["languageCode"])) 95 | break 96 | 97 | # Fetch tracks from HBBTV endpoint to check for potential H.265/2160p DASH 98 | r = self.session.get(self.config["endpoints"]["hbbtv"].format(item_id=title.id)) 99 | hbbtv = r.json() 100 | for stream in hbbtv["video"]["streams"]: 101 | for media in stream["media"]: 102 | if media["mimeType"] == "application/dash+xml" and media["audios"][0]["kind"] == "standard": 103 | tracks += Tracks(DASH.from_url(media["url"]).to_tracks(media["audios"][0]["languageCode"])) 104 | break 105 | 106 | # for stream in title.data["video"]["streams"]: 107 | # for media in stream["media"]: 108 | # if media["mimeType"] != "video/mp4" or media["audios"][0]["kind"] != "standard": 109 | # continue 110 | 111 | # tracks += Video( 112 | # codec=Video.Codec.AVC, # Should check media["videoCodec"] 113 | # range_=Video.Range.SDR, # Should check media["isHighDynamicRange"] 114 | # width=media["maxHResolutionPx"], 115 | # height=media["maxVResolutionPx"], 116 | # url=media["url"], 117 | # language=media["audios"][0]["languageCode"], 118 | # fps=50, 119 | # ) 120 | 121 | for sub in media_collection["subtitles"]: 122 | for source in sub["sources"]: 123 | if source["kind"] == "ebutt": 124 | tracks.add(Subtitle( 125 | codec=Subtitle.Codec.TimedTextMarkupLang, 126 | language=sub["languageCode"], 127 | url=source["url"] 128 | )) 129 | 130 | return tracks 131 | 132 | def get_chapters(self, title: Union[Episode, Movie]) -> list[Chapter]: 133 | return [] 134 | 135 | def load_player(self, item_id): 136 | r = self.session.get(self.config["endpoints"]["item"].format(item_id=item_id)) 137 | item = r.json() 138 | 139 | for widget in item["widgets"]: 140 | if widget["type"] != "player_ondemand": 141 | continue 142 | 143 | common_data = { 144 | "id_": item_id, 145 | "data": widget, 146 | "service": self.__class__, 147 | "language": "de", 148 | "year": widget["broadcastedOn"][0:4], 149 | } 150 | 151 | if widget["show"]["coreAssetType"] == "SINGLE" or not widget["show"].get("availableSeasons"): 152 | return Movies([Movie( 153 | name=widget["title"], 154 | **common_data 155 | )]) 156 | else: 157 | match = re.match(self.EPISODE_NAME_RE, widget["title"]) 158 | if not match: 159 | name = widget["title"] 160 | season = 0 161 | episode = 0 162 | else: 163 | name = match.group("name") 164 | season = match.group("season") or 0 165 | episode = match.group("episode") or 0 166 | 167 | return Series([Episode( 168 | name=name, 169 | title=widget["show"]["title"], 170 | #season=widget["show"]["availableSeasons"][0], 171 | season=season, 172 | number=episode, 173 | **common_data 174 | )]) 175 | 176 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # unshackle 2 | unshackle.yaml 3 | unshackle.yml 4 | update_check.json 5 | *.mkv 6 | *.mp4 7 | *.exe 8 | *.dll 9 | *.crt 10 | *.wvd 11 | *.prd 12 | *.der 13 | *.pem 14 | *.bin 15 | *.db 16 | *.ttf 17 | *.otf 18 | device_cert 19 | device_client_id_blob 20 | device_private_key 21 | device_vmp_blob 22 | unshackle/cache/ 23 | unshackle/cookies/ 24 | unshackle/certs/ 25 | unshackle/WVDs/ 26 | unshackle/PRDs/ 27 | temp/ 28 | logs/ 29 | services/ 30 | 31 | # Byte-compiled / optimized / DLL files 32 | __pycache__/ 33 | *.py[cod] 34 | *$py.class 35 | 36 | # C extensions 37 | *.so 38 | 39 | # Distribution / packaging 40 | .Python 41 | build/ 42 | develop-eggs/ 43 | dist/ 44 | downloads/ 45 | eggs/ 46 | .eggs/ 47 | lib/ 48 | lib64/ 49 | parts/ 50 | sdist/ 51 | var/ 52 | wheels/ 53 | share/python-wheels/ 54 | *.egg-info/ 55 | .installed.cfg 56 | *.egg 57 | MANIFEST 58 | 59 | # PyInstaller 60 | # Usually these files are written by a python script from a template 61 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 62 | *.manifest 63 | *.spec 64 | 65 | # Installer logs 66 | pip-log.txt 67 | pip-delete-this-directory.txt 68 | 69 | # Unit test / coverage reports 70 | htmlcov/ 71 | .tox/ 72 | .nox/ 73 | .coverage 74 | .coverage.* 75 | .cache 76 | nosetests.xml 77 | coverage.xml 78 | *.cover 79 | *.py,cover 80 | .hypothesis/ 81 | .pytest_cache/ 82 | cover/ 83 | 84 | # Translations 85 | *.mo 86 | *.pot 87 | 88 | # Django stuff: 89 | *.log 90 | local_settings.py 91 | db.sqlite3 92 | db.sqlite3-journal 93 | 94 | # Flask stuff: 95 | instance/ 96 | .webassets-cache 97 | 98 | # Scrapy stuff: 99 | .scrapy 100 | 101 | # Sphinx documentation 102 | docs/_build/ 103 | 104 | # PyBuilder 105 | .pybuilder/ 106 | target/ 107 | 108 | # Jupyter Notebook 109 | .ipynb_checkpoints 110 | 111 | # IPython 112 | profile_default/ 113 | ipython_config.py 114 | 115 | # pyenv 116 | # For a library or package, you might want to ignore these files since the code is 117 | # intended to run in multiple environments; otherwise, check them in: 118 | # .python-version 119 | 120 | # pipenv 121 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 122 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 123 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 124 | # install all needed dependencies. 125 | #Pipfile.lock 126 | 127 | # UV 128 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 129 | # This is especially recommended for binary packages to ensure reproducibility, and is more 130 | # commonly ignored for libraries. 131 | # uv.lock 132 | 133 | # poetry 134 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 135 | # This is especially recommended for binary packages to ensure reproducibility, and is more 136 | # commonly ignored for libraries. 137 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 138 | poetry.lock 139 | poetry.toml 140 | 141 | # pdm 142 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 143 | # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. 144 | # https://pdm-project.org/en/latest/usage/project/#working-with-version-control 145 | #pdm.lock 146 | #pdm.toml 147 | .pdm-python 148 | .pdm-build/ 149 | 150 | # pixi 151 | # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. 152 | #pixi.lock 153 | # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one 154 | # in the .venv directory. It is recommended not to include this directory in version control. 155 | .pixi 156 | 157 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 158 | __pypackages__/ 159 | 160 | # Celery stuff 161 | celerybeat-schedule 162 | celerybeat.pid 163 | 164 | # SageMath parsed files 165 | *.sage.py 166 | 167 | # Environments 168 | .env 169 | .envrc 170 | .venv 171 | env/ 172 | venv/ 173 | ENV/ 174 | env.bak/ 175 | venv.bak/ 176 | 177 | # Spyder project settings 178 | .spyderproject 179 | .spyproject 180 | 181 | # Rope project settings 182 | .ropeproject 183 | 184 | # mkdocs documentation 185 | /site 186 | 187 | # mypy 188 | .mypy_cache/ 189 | .dmypy.json 190 | dmypy.json 191 | 192 | # Pyre type checker 193 | .pyre/ 194 | 195 | # pytype static type analyzer 196 | .pytype/ 197 | 198 | # Cython debug symbols 199 | cython_debug/ 200 | 201 | # PyCharm 202 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 203 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 204 | # and can be added to the global gitignore or merged into this file. For a more nuclear 205 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 206 | #.idea/ 207 | 208 | # Abstra 209 | # Abstra is an AI-powered process automation framework. 210 | # Ignore directories containing user credentials, local state, and settings. 211 | # Learn more at https://abstra.io/docs 212 | .abstra/ 213 | 214 | # Visual Studio Code 215 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 216 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 217 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 218 | # you could uncomment the following to ignore the entire vscode folder 219 | .vscode/ 220 | .github/copilot-instructions.md 221 | CLAUDE.md 222 | 223 | # Ruff stuff: 224 | .ruff_cache/ 225 | 226 | # PyPI configuration file 227 | .pypirc 228 | 229 | # Cursor 230 | # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to 231 | # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data 232 | # refer to https://docs.cursor.com/context/ignore-files 233 | .cursorignore 234 | .cursorindexingignore 235 | 236 | # Marimo 237 | marimo/_static/ 238 | marimo/_lsp/ 239 | __marimo__/ 240 | -------------------------------------------------------------------------------- /unshackle/core/cacher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import zlib 4 | from datetime import datetime, timedelta 5 | from os import stat_result 6 | from pathlib import Path 7 | from typing import Any, Optional, Union 8 | 9 | import jsonpickle 10 | import jwt 11 | 12 | from unshackle.core.config import config 13 | 14 | EXP_T = Union[datetime, str, int, float] 15 | 16 | 17 | class Cacher: 18 | """Cacher for Services to get and set arbitrary data with expiration dates.""" 19 | 20 | def __init__( 21 | self, 22 | service_tag: str, 23 | key: Optional[str] = None, 24 | version: Optional[int] = 1, 25 | data: Optional[Any] = None, 26 | expiration: Optional[datetime] = None, 27 | ) -> None: 28 | self.service_tag = service_tag 29 | self.key = key 30 | self.version = version 31 | self.data = data or {} 32 | self.expiration = expiration 33 | 34 | if self.expiration and self.expired: 35 | # if its expired, remove the data for safety and delete cache file 36 | self.data = None 37 | self.path.unlink() 38 | 39 | def __bool__(self) -> bool: 40 | return bool(self.data) 41 | 42 | @property 43 | def path(self) -> Path: 44 | """Get the path at which the cache will be read and written.""" 45 | return (config.directories.cache / self.service_tag / self.key).with_suffix(".json") 46 | 47 | @property 48 | def expired(self) -> bool: 49 | return self.expiration and self.expiration < datetime.now() 50 | 51 | def get(self, key: str, version: int = 1) -> Cacher: 52 | """ 53 | Get Cached data for the Service by Key. 54 | :param key: the filename to save the data to, should be url-safe. 55 | :param version: the config data version you expect to use. 56 | :returns: Cache object containing the cached data or None if the file does not exist. 57 | """ 58 | cache = Cacher(self.service_tag, key, version) 59 | if cache.path.is_file(): 60 | data = jsonpickle.loads(cache.path.read_text(encoding="utf8")) 61 | payload = data.copy() 62 | del payload["crc32"] 63 | checksum = data["crc32"] 64 | calculated = zlib.crc32(jsonpickle.dumps(payload).encode("utf8")) 65 | if calculated != checksum: 66 | raise ValueError( 67 | f"The checksum of the Cache payload mismatched. Checksum: {checksum} !== Calculated: {calculated}" 68 | ) 69 | cache.data = data["data"] 70 | cache.expiration = data["expiration"] 71 | cache.version = data["version"] 72 | if cache.version != version: 73 | raise ValueError( 74 | f"The version of your {self.service_tag} {key} cache is outdated. Please delete: {cache.path}" 75 | ) 76 | return cache 77 | 78 | def set(self, data: Any, expiration: Optional[EXP_T] = None) -> Any: 79 | """ 80 | Set Cached data for the Service by Key. 81 | :param data: absolutely anything including None. 82 | :param expiration: when the data expires, optional. Can be ISO 8601, seconds 83 | til expiration, unix timestamp, or a datetime object. 84 | :returns: the data provided for quick wrapping of functions or vars. 85 | """ 86 | self.data = data 87 | 88 | if not expiration: 89 | try: 90 | expiration = jwt.decode(self.data, options={"verify_signature": False})["exp"] 91 | except jwt.DecodeError: 92 | pass 93 | 94 | self.expiration = self.resolve_datetime(expiration) if expiration else None 95 | 96 | payload = {"data": self.data, "expiration": self.expiration, "version": self.version} 97 | payload["crc32"] = zlib.crc32(jsonpickle.dumps(payload).encode("utf8")) 98 | 99 | self.path.parent.mkdir(parents=True, exist_ok=True) 100 | self.path.write_text(jsonpickle.dumps(payload)) 101 | 102 | return self.data 103 | 104 | def stat(self) -> stat_result: 105 | """ 106 | Get Cache file OS Stat data like Creation Time, Modified Time, and such. 107 | :returns: an os.stat_result tuple 108 | """ 109 | return self.path.stat() 110 | 111 | @staticmethod 112 | def resolve_datetime(timestamp: EXP_T) -> datetime: 113 | """ 114 | Resolve multiple formats of a Datetime or Timestamp to an absolute Datetime. 115 | 116 | Examples: 117 | >>> now = datetime.now() 118 | datetime.datetime(2022, 6, 27, 9, 49, 13, 657208) 119 | >>> iso8601 = now.isoformat() 120 | '2022-06-27T09:49:13.657208' 121 | >>> Cacher.resolve_datetime(iso8601) 122 | datetime.datetime(2022, 6, 27, 9, 49, 13, 657208) 123 | >>> Cacher.resolve_datetime(iso8601 + "Z") 124 | datetime.datetime(2022, 6, 27, 9, 49, 13, 657208) 125 | >>> Cacher.resolve_datetime(3600) 126 | datetime.datetime(2022, 6, 27, 10, 52, 50, 657208) 127 | >>> Cacher.resolve_datetime('3600') 128 | datetime.datetime(2022, 6, 27, 10, 52, 51, 657208) 129 | >>> Cacher.resolve_datetime(7800.113) 130 | datetime.datetime(2022, 6, 27, 11, 59, 13, 770208) 131 | 132 | In the int/float examples you may notice that it did not return now + 3600 seconds 133 | but rather something a bit more than that. This is because it did not resolve 3600 134 | seconds from the `now` variable but from right now as the function was called. 135 | """ 136 | if isinstance(timestamp, datetime): 137 | return timestamp 138 | if isinstance(timestamp, str): 139 | if timestamp.endswith("Z"): 140 | # fromisoformat doesn't accept the final Z 141 | timestamp = timestamp.split("Z")[0] 142 | try: 143 | return datetime.fromisoformat(timestamp) 144 | except ValueError: 145 | timestamp = float(timestamp) 146 | try: 147 | if len(str(int(timestamp))) == 13: # JS-style timestamp 148 | timestamp /= 1000 149 | timestamp = datetime.fromtimestamp(timestamp) 150 | except ValueError: 151 | raise ValueError(f"Unrecognized Timestamp value {timestamp!r}") 152 | if timestamp < datetime.now(): 153 | # timestamp is likely an amount of seconds til expiration 154 | # or, it's an already expired timestamp which is unlikely 155 | timestamp = timestamp + timedelta(seconds=datetime.now().timestamp()) 156 | return timestamp 157 | --------------------------------------------------------------------------------