Fixed login for KOCW and Added Playready support for PTHS
This commit is contained in:
parent
8289b3a709
commit
22bb10cddf
@ -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 []
|
||||||
|
|
||||||
@ -16,24 +16,26 @@ 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 = (
|
||||||
r"^(?:https?://(?:www\.)?pathe-thuis\.nl/film/)?(?P<id>\d+)(?:/[^/]+)?$"
|
r"^(?:https?://(?:www\.)?pathe-thuis\.nl/film/)?(?P<id>\d+)(?:/[^/]+)?$"
|
||||||
)
|
)
|
||||||
GEOFENCE = ("NL",)
|
GEOFENCE = ("NL",)
|
||||||
NO_SUBTITLES = True
|
NO_SUBTITLES = True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@click.command(name="PTHS", short_help="https://www.pathe-thuis.nl")
|
@click.command(name="PTHS", short_help="https://www.pathe-thuis.nl")
|
||||||
@ -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,16 +99,16 @@ 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()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
@ -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)
|
||||||
@ -146,4 +188,4 @@ class PTHS(Service):
|
|||||||
|
|
||||||
if not r.content:
|
if not r.content:
|
||||||
raise ValueError("Empty license response, likely invalid or expired token.")
|
raise ValueError("Empty license response, likely invalid or expired token.")
|
||||||
return r.content
|
return r.content
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user