.+)$"
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 |
unshackle
3 |
4 | Movie, TV, and Music Archival Software
5 |
6 |
7 |
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 |
--------------------------------------------------------------------------------