diff --git a/KOWP/__init__.py b/KOCW/__init__.py similarity index 92% rename from KOWP/__init__.py rename to KOCW/__init__.py index 7602c0c..0b43449 100644 --- a/KOWP/__init__.py +++ b/KOCW/__init__.py @@ -14,8 +14,10 @@ from unshackle.core.search_result import SearchResult from unshackle.core.titles import Episode, Series, Title_T, Titles_T from unshackle.core.tracks import Subtitle, Tracks from unshackle.core.utilities import is_close_match +import uuid +import hashlib -class KOWP(Service): +class KOCW(Service): """ Service code for Kocowa Plus (kocowa.com). Version: 1.0.0 @@ -29,12 +31,12 @@ class KOWP(Service): NO_SUBTITLES = False @staticmethod - @click.command(name="kowp", short_help="https://www.kocowa.com") + @click.command(name="kocw", short_help="https://www.kocowa.com") @click.argument("title", type=str) @click.option("--extras", is_flag=True, default=False, help="Include teasers/extras") @click.pass_context def cli(ctx, **kwargs): - return KOWP(ctx, **kwargs) + return KOCW(ctx, **kwargs) def __init__(self, ctx, title: str, extras: bool = False): super().__init__(ctx) @@ -52,16 +54,27 @@ class KOWP(Service): if not credential: raise ValueError("KOWP requires username and password") + + email = credential.username.lower().strip() + uuid_seed = hashlib.md5(email.encode()).digest() + fake_uuid = str(uuid.UUID(bytes=uuid_seed[:16])) + + device_id = f"a_{fake_uuid}_{email}" + push_token = "fkiTs_a0SAaMYx957n-qA-:APA91bFb39IjJd_iA5bVmh-fjvaUKonvKDWw1PfKKcdpkSXanj0Jlevv_QlMPPD5ZykAQE4ELa3bs6p-Gnmz0R54U-B1o1ukBPLQEDLDdM3hU2ozZIRiy9I" + payload = { "username": credential.username, "password": credential.password, - "device_id": f"{credential.username}_browser", - "device_type": "browser", - "device_model": "Firefox", - "device_version": "firefox/143.0", + "device_id": device_id, + "device_type": "mobile", + "device_model": "SM-A525F", + "device_version": "Android 15", "push_token": None, - "app_version": "v4.0.16", + "app_version": "v4.0.11", } + + self.log.debug(f"Authenticating with device_id: {device_id}") + r = self.session.post( self.config["endpoints"]["login"], json=payload, @@ -294,4 +307,3 @@ class KOWP(Service): def get_chapters(self, title: Title_T) -> list: return [] - diff --git a/KOWP/config.yaml b/KOCW/config.yaml similarity index 100% rename from KOWP/config.yaml rename to KOCW/config.yaml diff --git a/PTHS/__init__.py b/PTHS/__init__.py index 33df9e3..2c8acd2 100644 --- a/PTHS/__init__.py +++ b/PTHS/__init__.py @@ -16,24 +16,26 @@ from unshackle.core.tracks import Tracks class PTHS(Service): """ Service code for Pathé Thuis (pathe-thuis.nl) - Version: 1.0.0 + Version: 1.1.0 (PlayReady Support Added) - Security: SD @ L3 (Widevine) - FHD @ L1 - Authorization: Cookies or authentication token + Security: SD/FHD @ L1/L3 (Widevine) + SD/FHD @ SL2K/SL3K (Playready) + Authorization: Cookies with authenticationToken + XSRF-TOKEN Supported: • Movies → https://www.pathe-thuis.nl/film/{id} Note: Pathé Thuis does not have episodic content, only movies. + Subtitles are hardcoded here so yeah I can't do anything about it + The quality is depend on what you rented for, is it SD or HD? """ TITLE_RE = ( r"^(?:https?://(?:www\.)?pathe-thuis\.nl/film/)?(?P\d+)(?:/[^/]+)?$" ) GEOFENCE = ("NL",) - NO_SUBTITLES = True + NO_SUBTITLES = True @staticmethod @click.command(name="PTHS", short_help="https://www.pathe-thuis.nl") @@ -44,17 +46,15 @@ class PTHS(Service): def __init__(self, ctx, title: str): super().__init__(ctx) - m = re.match(self.TITLE_RE, title) if not m: raise ValueError( f"Unsupported Pathé Thuis URL or ID: {title}\n" "Use e.g. https://www.pathe-thuis.nl/film/30591" ) - self.movie_id = m.group("id") self.drm_token = None - + self.license_url = None if self.config is None: raise EnvironmentError("Missing service config for Pathé Thuis.") @@ -65,18 +65,27 @@ class PTHS(Service): self.log.warning("No cookies provided, proceeding unauthenticated.") return - token = next((c.value for c in cookies if c.name == "authenticationToken"), None) - if not token: + # Extract critical cookies + auth_token = next((c.value for c in cookies if c.name == "authenticationToken"), None) + xsrf_token = next((c.value for c in cookies if c.name == "XSRF-TOKEN"), None) + + if not auth_token: self.log.info("No authenticationToken cookie found, unauthenticated mode.") return - self.session.headers.update({ - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0", - "X-Pathe-Device-Identifier": "web-widevine-1", - "X-Pathe-Auth-Session-Token": token, - }) - self.log.info("Authentication token successfully attached to session.") - + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0", + "X-Pathe-Device-Identifier": "web-1", + "X-Pathe-Auth-Session-Token": auth_token, + } + + if xsrf_token: + headers["X-XSRF-TOKEN"] = xsrf_token + self.log.debug(f"XSRF-TOKEN header set: {xsrf_token[:10]}...") + + self.session.headers.update(headers) + auth_status = "with XSRF" if xsrf_token else "without XSRF" + self.log.info(f"Authentication token attached ({auth_status}).") def get_titles(self) -> Titles_T: url = self.config["endpoints"]["metadata"].format(movie_id=self.movie_id) @@ -90,16 +99,16 @@ class PTHS(Service): name=data["name"], description=data.get("intro", ""), year=data.get("year"), - language=Language.get(data.get("language", "en")), + language=Language.get(data.get("language", "nl")), # Default to Dutch data=data, ) return Movies([movie]) - def get_tracks(self, title: Title_T) -> Tracks: ticket_id = self._get_ticket_id(title) - url = self.config["endpoints"]["ticket"].format(ticket_id=ticket_id) - + base_url = self.config["endpoints"]["ticket"].format(ticket_id=ticket_id) + url = f"{base_url}?drmType=dash-widevine" + r = self.session.get(url) r.raise_for_status() data = r.json() @@ -107,16 +116,17 @@ class PTHS(Service): manifest_url = stream.get("url") or stream.get("drmurl") if not manifest_url: - raise ValueError("No stream manifest URL found.") + raise ValueError("No stream manifest URL found in ticket response.") + # Store DRM context for license acquisition self.drm_token = stream["token"] self.license_url = stream["rawData"]["licenseserver"] + drm_type = stream["rawData"].get("type", "unknown") + self.log.info(f"Acquired {drm_type.upper()} stream manifest. License URL set.") tracks = DASH.from_url(manifest_url, session=self.session).to_tracks(language=title.language) - return tracks - def _get_ticket_id(self, title: Title_T) -> str: """Fetch the user's owned ticket ID if present.""" data = title.data @@ -125,12 +135,45 @@ class PTHS(Service): return str(t["id"]) raise ValueError("No valid ticket found for this movie. Ensure purchase or login.") - def get_chapters(self, title: Title_T): return [] + def get_playready_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes: + """ + Acquire PlayReady license using the authentication token. + Matches the license request pattern observed in browser traffic. + """ + if not self.license_url or not self.drm_token: + raise ValueError("Missing license URL or DRM token. Call get_tracks() first.") + + headers = { + "Content-Type": "application/octet-stream", + "Authorization": f"Bearer {self.drm_token}", + + } + params = {"custom_data": self.drm_token} + + self.log.debug(f"Requesting PlayReady license from {self.license_url}") + r = self.session.post( + self.license_url, + params=params, + data=challenge, + headers=headers, + timeout=10 + ) + r.raise_for_status() + + if not r.content or len(r.content) < 10: + raise ValueError( + "Invalid PlayReady license response. " + "Check: 1) Valid session 2) XSRF token 3) Active rental/purchase" + ) + + self.log.info(f"Successfully acquired PlayReady license ({len(r.content)} bytes)") + return r.content def get_widevine_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes: + """Widevine license acquisition . """ if not self.license_url or not self.drm_token: raise ValueError("Missing license URL or token.") @@ -138,7 +181,6 @@ class PTHS(Service): "Content-Type": "application/octet-stream", "Authorization": f"Bearer {self.drm_token}", } - params = {"custom_data": self.drm_token} r = self.session.post(self.license_url, params=params, data=challenge, headers=headers) @@ -146,4 +188,4 @@ class PTHS(Service): if not r.content: raise ValueError("Empty license response, likely invalid or expired token.") - return r.content \ No newline at end of file + return r.content diff --git a/README.md b/README.md index 13bc568..ebb04ce 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ - Audio mislabel as English - To add Playready Support 3. PTHS: - - To add Playready Support (is needed since L3 is just 480p) - Search Functionality - Account login if possible 4. HIDI: @@ -34,7 +33,7 @@ 10. SKST (the hardest service I ever dealt upon now): - Subtitle has been fixed, hopefully no issue 11. VLD: - - So far no issue + - Token isn't cached so that's a major problem with series - Acknowledgment