Fixed login for KOCW and Added Playready support for PTHS

This commit is contained in:
FairTrade 2026-02-02 12:43:31 +01:00
parent 8289b3a709
commit 22bb10cddf
4 changed files with 91 additions and 38 deletions

View File

@ -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.titles import Episode, Series, Title_T, Titles_T
from unshackle.core.tracks import Subtitle, Tracks from unshackle.core.tracks import Subtitle, Tracks
from unshackle.core.utilities import is_close_match 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). Service code for Kocowa Plus (kocowa.com).
Version: 1.0.0 Version: 1.0.0
@ -29,12 +31,12 @@ class KOWP(Service):
NO_SUBTITLES = False NO_SUBTITLES = False
@staticmethod @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.argument("title", type=str)
@click.option("--extras", is_flag=True, default=False, help="Include teasers/extras") @click.option("--extras", is_flag=True, default=False, help="Include teasers/extras")
@click.pass_context @click.pass_context
def cli(ctx, **kwargs): def cli(ctx, **kwargs):
return KOWP(ctx, **kwargs) return KOCW(ctx, **kwargs)
def __init__(self, ctx, title: str, extras: bool = False): def __init__(self, ctx, title: str, extras: bool = False):
super().__init__(ctx) super().__init__(ctx)
@ -52,16 +54,27 @@ class KOWP(Service):
if not credential: if not credential:
raise ValueError("KOWP requires username and password") 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 = { payload = {
"username": credential.username, "username": credential.username,
"password": credential.password, "password": credential.password,
"device_id": f"{credential.username}_browser", "device_id": device_id,
"device_type": "browser", "device_type": "mobile",
"device_model": "Firefox", "device_model": "SM-A525F",
"device_version": "firefox/143.0", "device_version": "Android 15",
"push_token": None, "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( r = self.session.post(
self.config["endpoints"]["login"], self.config["endpoints"]["login"],
json=payload, json=payload,
@ -294,4 +307,3 @@ class KOWP(Service):
def get_chapters(self, title: Title_T) -> list: def get_chapters(self, title: Title_T) -> list:
return [] return []

View File

@ -16,17 +16,19 @@ from unshackle.core.tracks import Tracks
class PTHS(Service): class PTHS(Service):
""" """
Service code for Pathé Thuis (pathe-thuis.nl) Service code for Pathé Thuis (pathe-thuis.nl)
Version: 1.0.0 Version: 1.1.0 (PlayReady Support Added)
Security: SD @ L3 (Widevine) Security: SD/FHD @ L1/L3 (Widevine)
FHD @ L1 SD/FHD @ SL2K/SL3K (Playready)
Authorization: Cookies or authentication token Authorization: Cookies with authenticationToken + XSRF-TOKEN
Supported: Supported:
Movies https://www.pathe-thuis.nl/film/{id} Movies https://www.pathe-thuis.nl/film/{id}
Note: Note:
Pathé Thuis does not have episodic content, only movies. 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 = ( TITLE_RE = (
@ -44,17 +46,15 @@ class PTHS(Service):
def __init__(self, ctx, title: str): def __init__(self, ctx, title: str):
super().__init__(ctx) super().__init__(ctx)
m = re.match(self.TITLE_RE, title) m = re.match(self.TITLE_RE, title)
if not m: if not m:
raise ValueError( raise ValueError(
f"Unsupported Pathé Thuis URL or ID: {title}\n" f"Unsupported Pathé Thuis URL or ID: {title}\n"
"Use e.g. https://www.pathe-thuis.nl/film/30591" "Use e.g. https://www.pathe-thuis.nl/film/30591"
) )
self.movie_id = m.group("id") self.movie_id = m.group("id")
self.drm_token = None self.drm_token = None
self.license_url = None
if self.config is None: if self.config is None:
raise EnvironmentError("Missing service config for Pathé Thuis.") raise EnvironmentError("Missing service config for Pathé Thuis.")
@ -65,18 +65,27 @@ class PTHS(Service):
self.log.warning("No cookies provided, proceeding unauthenticated.") self.log.warning("No cookies provided, proceeding unauthenticated.")
return return
token = next((c.value for c in cookies if c.name == "authenticationToken"), None) # Extract critical cookies
if not token: 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.") self.log.info("No authenticationToken cookie found, unauthenticated mode.")
return return
self.session.headers.update({ headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0", "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-widevine-1", "X-Pathe-Device-Identifier": "web-1",
"X-Pathe-Auth-Session-Token": token, "X-Pathe-Auth-Session-Token": auth_token,
}) }
self.log.info("Authentication token successfully attached to session.")
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: def get_titles(self) -> Titles_T:
url = self.config["endpoints"]["metadata"].format(movie_id=self.movie_id) url = self.config["endpoints"]["metadata"].format(movie_id=self.movie_id)
@ -90,15 +99,15 @@ class PTHS(Service):
name=data["name"], name=data["name"],
description=data.get("intro", ""), description=data.get("intro", ""),
year=data.get("year"), year=data.get("year"),
language=Language.get(data.get("language", "en")), language=Language.get(data.get("language", "nl")), # Default to Dutch
data=data, data=data,
) )
return Movies([movie]) return Movies([movie])
def get_tracks(self, title: Title_T) -> Tracks: def get_tracks(self, title: Title_T) -> Tracks:
ticket_id = self._get_ticket_id(title) 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 = self.session.get(url)
r.raise_for_status() r.raise_for_status()
@ -107,16 +116,17 @@ class PTHS(Service):
manifest_url = stream.get("url") or stream.get("drmurl") manifest_url = stream.get("url") or stream.get("drmurl")
if not manifest_url: 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.drm_token = stream["token"]
self.license_url = stream["rawData"]["licenseserver"] 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) tracks = DASH.from_url(manifest_url, session=self.session).to_tracks(language=title.language)
return tracks return tracks
def _get_ticket_id(self, title: Title_T) -> str: def _get_ticket_id(self, title: Title_T) -> str:
"""Fetch the user's owned ticket ID if present.""" """Fetch the user's owned ticket ID if present."""
data = title.data data = title.data
@ -125,12 +135,45 @@ class PTHS(Service):
return str(t["id"]) return str(t["id"])
raise ValueError("No valid ticket found for this movie. Ensure purchase or login.") raise ValueError("No valid ticket found for this movie. Ensure purchase or login.")
def get_chapters(self, title: Title_T): def get_chapters(self, title: Title_T):
return [] 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: 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: if not self.license_url or not self.drm_token:
raise ValueError("Missing license URL or token.") raise ValueError("Missing license URL or token.")
@ -138,7 +181,6 @@ class PTHS(Service):
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
"Authorization": f"Bearer {self.drm_token}", "Authorization": f"Bearer {self.drm_token}",
} }
params = {"custom_data": self.drm_token} params = {"custom_data": self.drm_token}
r = self.session.post(self.license_url, params=params, data=challenge, headers=headers) r = self.session.post(self.license_url, params=params, data=challenge, headers=headers)

View File

@ -14,7 +14,6 @@
- Audio mislabel as English - Audio mislabel as English
- To add Playready Support - To add Playready Support
3. PTHS: 3. PTHS:
- To add Playready Support (is needed since L3 is just 480p)
- Search Functionality - Search Functionality
- Account login if possible - Account login if possible
4. HIDI: 4. HIDI:
@ -34,7 +33,7 @@
10. SKST (the hardest service I ever dealt upon now): 10. SKST (the hardest service I ever dealt upon now):
- Subtitle has been fixed, hopefully no issue - Subtitle has been fixed, hopefully no issue
11. VLD: 11. VLD:
- So far no issue - Token isn't cached so that's a major problem with series
- Acknowledgment - Acknowledgment